From: Timo Tijhof Date: Wed, 9 May 2018 20:36:06 +0000 (+0100) Subject: resources: Move the remaining src/mediawiki/ files X-Git-Tag: 1.34.0-rc.0~5462^2 X-Git-Url: http://git.cyclocoop.org/%22.%24match%5B1%5D.%22?a=commitdiff_plain;h=16c8d893575ba006993220cca9834f5b0ec92a66;p=lhc%2Fweb%2Fwiklou.git resources: Move the remaining src/mediawiki/ files Single-file modules to src/, the remaining as sub directories. A few highlights: * mediawiki.Upload.BookletLayout. (stylesheet: no image references) * mediawiki.feedback - Also move the image to its own images/ subdir. * mediawiki.searchSuggest. (stylesheet: no image references) * mediawiki.toc. (stylesheet: no image references) Also updated any other references to 'src/mediawiki/' that I could find in core: * Fixed references in docs/uidesign/*.html * Remove redundant exclude from jsduck.json. After this, there are 4 files remaining in src/mediawiki, which are the 4 files used by the actual 'mediawiki' base module. Bug: T193826 Change-Id: I8058652892a78b3f5976397bd850741dd5c92427 --- diff --git a/Gruntfile.js b/Gruntfile.js index 2f5586862f..3687d2805e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -36,7 +36,7 @@ module.exports = function ( grunt ) { '!extensions/**/*.js', '!skins/**/*.js', // Skip functions aren't even parseable - '!resources/src/mediawiki.hidpi-skip.js' + '!resources/src/mediawiki.hidpi/skip.js' ] }, jsonlint: { diff --git a/docs/uidesign/design.html b/docs/uidesign/design.html index 6ab57d7d4f..8395cd5bb7 100644 --- a/docs/uidesign/design.html +++ b/docs/uidesign/design.html @@ -2,7 +2,7 @@ - + diff --git a/docs/uidesign/mediawiki.diff.html b/docs/uidesign/mediawiki.diff.html index cd13dbac20..651cac1661 100644 --- a/docs/uidesign/mediawiki.diff.html +++ b/docs/uidesign/mediawiki.diff.html @@ -2,8 +2,8 @@ - - + + diff --git a/jsduck.json b/jsduck.json index 40af236ff0..18d514f0c6 100644 --- a/jsduck.json +++ b/jsduck.json @@ -21,7 +21,6 @@ "resources/src/mediawiki.legacy", "resources/src/mediawiki.libs.jpegmeta/jpegmeta.js", "resources/src/mediawiki.skinning", - "resources/src/mediawiki/mediawiki.Title.phpCharToUpper.js", "resources/src/startup.js" ], "--": [ diff --git a/resources/Resources.php b/resources/Resources.php index 81a32c2448..ea4e5eafe7 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -865,7 +865,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.apihelp' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.apihelp.css', + 'styles' => 'resources/src/mediawiki.apihelp.css', 'targets' => [ 'desktop' ], ], 'mediawiki.template' => [ @@ -875,7 +875,7 @@ return [ 'mediawiki.template.mustache' => [ 'scripts' => [ 'resources/lib/mustache/mustache.js', - 'resources/src/mediawiki/mediawiki.template.mustache.js', + 'resources/src/mediawiki.template.mustache.js', ], 'targets' => [ 'desktop', 'mobile' ], 'dependencies' => 'mediawiki.template', @@ -961,20 +961,20 @@ return [ ], ], 'mediawiki.content.json' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.content.json.less', + 'styles' => 'resources/src/mediawiki.content.json.less', ], 'mediawiki.confirmCloseWindow' => [ 'scripts' => [ - 'resources/src/mediawiki/mediawiki.confirmCloseWindow.js', + 'resources/src/mediawiki.confirmCloseWindow.js', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.debug' => [ 'scripts' => [ - 'resources/src/mediawiki/mediawiki.debug.js', + 'resources/src/mediawiki.debug/debug.js', ], 'styles' => [ - 'resources/src/mediawiki/mediawiki.debug.less', + 'resources/src/mediawiki.debug/debug.less', ], 'dependencies' => [ 'jquery.footHovzer', @@ -983,16 +983,16 @@ return [ ], 'mediawiki.diff.styles' => [ 'styles' => [ - 'resources/src/mediawiki/mediawiki.diff.styles.css', - 'resources/src/mediawiki/mediawiki.diff.styles.print.css' => [ + 'resources/src/mediawiki.diff.styles/diff.css', + 'resources/src/mediawiki.diff.styles/print.css' => [ 'media' => 'print' ], ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.feedback' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.feedback.js', - 'styles' => 'resources/src/mediawiki/mediawiki.feedback.css', + 'scripts' => 'resources/src/mediawiki.feedback/feedback.js', + 'styles' => 'resources/src/mediawiki.feedback/feedback.css', 'dependencies' => [ 'mediawiki.messagePoster', 'mediawiki.Title', @@ -1026,11 +1026,11 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.feedlink' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.feedlink.css', + 'styles' => 'resources/src/mediawiki.feedlink/feedlink.css', ], 'mediawiki.filewarning' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.filewarning.js', - 'styles' => 'resources/src/mediawiki/mediawiki.filewarning.less', + 'scripts' => 'resources/src/mediawiki.filewarning/filewarning.js', + 'styles' => 'resources/src/mediawiki.filewarning/filewarning.less', 'dependencies' => [ 'oojs-ui-core', 'oojs-ui.styles.icons-alerts', @@ -1052,23 +1052,23 @@ return [ ], 'mediawiki.helplink' => [ 'styles' => [ - 'resources/src/mediawiki/mediawiki.helplink.less', + 'resources/src/mediawiki.helplink/helplink.less', ], 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.hidpi' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.hidpi.js', + 'scripts' => 'resources/src/mediawiki.hidpi/hidpi.js', 'dependencies' => 'jquery.hidpi', - 'skipFunction' => 'resources/src/mediawiki.hidpi-skip.js', + 'skipFunction' => 'resources/src/mediawiki.hidpi/skip.js', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.hlist' => [ 'targets' => [ 'desktop', 'mobile' ], 'styles' => [ - 'resources/src/mediawiki/mediawiki.hlist-allskins.less', + 'resources/src/mediawiki.hlist/hlist.less', ], 'skinStyles' => [ - 'default' => 'resources/src/mediawiki/mediawiki.hlist.css', + 'default' => 'resources/src/mediawiki.hlist/default.css', ], ], 'mediawiki.htmlform' => [ @@ -1121,7 +1121,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.icon' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.icon.less', + 'styles' => 'resources/src/mediawiki.icon/icon.less', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.inspect' => [ @@ -1196,12 +1196,12 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.pager.tablePager' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.pager.tablePager.less', + 'styles' => 'resources/src/mediawiki.pager.tablePager/TablePager.less', ], 'mediawiki.searchSuggest' => [ 'targets' => [ 'desktop', 'mobile' ], - 'scripts' => 'resources/src/mediawiki/mediawiki.searchSuggest.js', - 'styles' => 'resources/src/mediawiki/mediawiki.searchSuggest.css', + 'scripts' => 'resources/src/mediawiki.searchSuggest/searchSuggest.js', + 'styles' => 'resources/src/mediawiki.searchSuggest/searchSuggest.css', 'messages' => [ 'searchsuggest-search', 'searchsuggest-containing', @@ -1218,8 +1218,8 @@ return [ ], 'mediawiki.Title' => [ 'scripts' => [ - 'resources/src/mediawiki/mediawiki.Title.js', - 'resources/src/mediawiki/mediawiki.Title.phpCharToUpper.js', + 'resources/src/mediawiki.Title/Title.js', + 'resources/src/mediawiki.Title/phpCharToUpper.js', ], 'dependencies' => [ 'mediawiki.String', @@ -1277,10 +1277,10 @@ return [ ], 'mediawiki.Upload.BookletLayout' => [ 'scripts' => [ - 'resources/src/mediawiki/mediawiki.Upload.BookletLayout.js', + 'resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js', ], 'styles' => [ - 'resources/src/mediawiki/mediawiki.Upload.BookletLayout.css', + 'resources/src/mediawiki.Upload.BookletLayout/BookletLayout.css', ], 'dependencies' => [ 'oojs-ui-core', @@ -1322,8 +1322,8 @@ return [ ], ], 'mediawiki.ForeignStructuredUpload.BookletLayout' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.js', - 'styles' => 'resources/src/mediawiki/mediawiki.ForeignStructuredUpload.BookletLayout.less', + 'scripts' => 'resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js', + 'styles' => 'resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.less', 'dependencies' => [ 'mediawiki.ForeignStructuredUpload', 'mediawiki.Upload.BookletLayout', @@ -1347,11 +1347,11 @@ return [ ], ], 'mediawiki.toc' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.toc.js', + 'scripts' => 'resources/src/mediawiki.toc/toc.js', 'styles' => [ - 'resources/src/mediawiki/mediawiki.toc.css' + 'resources/src/mediawiki.toc/toc.css' => [ 'media' => 'screen' ], - 'resources/src/mediawiki/mediawiki.toc.print.css' + 'resources/src/mediawiki.toc/print.css' => [ 'media' => 'print' ], ], 'dependencies' => 'mediawiki.cookie', @@ -1359,10 +1359,10 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.Uri' => [ - 'scripts' => 'resources/src/mediawiki/mediawiki.Uri.js', + 'scripts' => 'resources/src/mediawiki.Uri/Uri.js', 'templates' => [ - 'strict.regexp' => 'resources/src/mediawiki/mediawiki.Uri.strict.regexp', - 'loose.regexp' => 'resources/src/mediawiki/mediawiki.Uri.loose.regexp', + 'strict.regexp' => 'resources/src/mediawiki.Uri/strict.regexp', + 'loose.regexp' => 'resources/src/mediawiki.Uri/loose.regexp', ], 'dependencies' => 'mediawiki.util', 'targets' => [ 'desktop', 'mobile' ], @@ -1421,7 +1421,7 @@ return [ 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.editfont.styles' => [ - 'styles' => 'resources/src/mediawiki/mediawiki.editfont.less', + 'styles' => 'resources/src/mediawiki.editfont.less', 'targets' => [ 'desktop', 'mobile' ], ], 'mediawiki.visibleTimeout' => [ @@ -1641,7 +1641,7 @@ return [ 'mediawiki.jqueryMsg' => [ // Add data for mediawiki.jqueryMsg, such as allowed tags 'class' => ResourceLoaderJqueryMsgModule::class, - 'scripts' => 'resources/src/mediawiki/mediawiki.jqueryMsg.js', + 'scripts' => 'resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js', 'dependencies' => [ 'mediawiki.util', 'mediawiki.language', diff --git a/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js b/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js new file mode 100644 index 0000000000..7d4ed537d7 --- /dev/null +++ b/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js @@ -0,0 +1,461 @@ +/* global moment, Uint8Array */ +( function ( $, mw ) { + + /** + * mw.ForeignStructuredUpload.BookletLayout encapsulates the process + * of uploading a file to MediaWiki using the mw.ForeignStructuredUpload model. + * + * var uploadDialog = new mw.Upload.Dialog( { + * bookletClass: mw.ForeignStructuredUpload.BookletLayout, + * booklet: { + * target: 'local' + * } + * } ); + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * windowManager.addWindows( [ uploadDialog ] ); + * + * @class mw.ForeignStructuredUpload.BookletLayout + * @uses mw.ForeignStructuredUpload + * @extends mw.Upload.BookletLayout + * + * @constructor + * @param {Object} config Configuration options + * @cfg {string} [target] Used to choose the target repository. + * If nothing is passed, the {@link mw.ForeignUpload#property-target default} is used. + */ + mw.ForeignStructuredUpload.BookletLayout = function ( config ) { + config = config || {}; + // Parent constructor + mw.ForeignStructuredUpload.BookletLayout.parent.call( this, config ); + + this.target = config.target; + }; + + /* Setup */ + + OO.inheritClass( mw.ForeignStructuredUpload.BookletLayout, mw.Upload.BookletLayout ); + + /* Uploading */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.initialize = function () { + var booklet = this; + return mw.ForeignStructuredUpload.BookletLayout.parent.prototype.initialize.call( this ).then( + function () { + return $.when( + // Point the CategoryMultiselectWidget to the right wiki + booklet.upload.getApi().then( function ( api ) { + // If this is a ForeignApi, it will have a apiUrl, otherwise we don't need to do anything + if ( api.apiUrl ) { + // Can't reuse the same object, CategoryMultiselectWidget calls #abort on its mw.Api instance + booklet.categoriesWidget.api = new mw.ForeignApi( api.apiUrl ); + } + return $.Deferred().resolve(); + } ), + // Set up booklet fields and license messages to match configuration + booklet.upload.loadConfig().then( function ( config ) { + var + msgPromise, + isLocal = booklet.upload.target === 'local', + fields = config.fields, + msgs = config.licensemessages[ isLocal ? 'local' : 'foreign' ]; + + // Hide disabled fields + booklet.descriptionField.toggle( !!fields.description ); + booklet.categoriesField.toggle( !!fields.categories ); + booklet.dateField.toggle( !!fields.date ); + // Update form validity + booklet.onInfoFormChange(); + + // Load license messages from the remote wiki if we don't have these messages locally + // (this means that we only load messages from the foreign wiki for custom config) + if ( mw.message( 'upload-form-label-own-work-message-' + msgs ).exists() ) { + msgPromise = $.Deferred().resolve(); + } else { + msgPromise = booklet.upload.apiPromise.then( function ( api ) { + return api.loadMessages( [ + 'upload-form-label-own-work-message-' + msgs, + 'upload-form-label-not-own-work-message-' + msgs, + 'upload-form-label-not-own-work-local-' + msgs + ] ); + } ); + } + + // Update license messages + return msgPromise.then( function () { + var $labels; + booklet.$ownWorkMessage.msg( 'upload-form-label-own-work-message-' + msgs ); + booklet.$notOwnWorkMessage.msg( 'upload-form-label-not-own-work-message-' + msgs ); + booklet.$notOwnWorkLocal.msg( 'upload-form-label-not-own-work-local-' + msgs ); + + $labels = $( [ + booklet.$ownWorkMessage[ 0 ], + booklet.$notOwnWorkMessage[ 0 ], + booklet.$notOwnWorkLocal[ 0 ] + ] ); + + // Improve the behavior of links inside these labels, which may point to important + // things like licensing requirements or terms of use + $labels.find( 'a' ) + .attr( 'target', '_blank' ) + .on( 'click', function ( e ) { + // OO.ui.FieldLayout#onLabelClick is trying to prevent default on all clicks, + // which causes the links to not be openable. Don't let it do that. + e.stopPropagation(); + } ); + } ); + }, function ( errorMsg ) { + booklet.getPage( 'upload' ).$element.msg( errorMsg ); + return $.Deferred().resolve(); + } ) + ); + } + ).catch( + // Always resolve, never reject + function () { return $.Deferred().resolve(); } + ); + }; + + /** + * Returns a {@link mw.ForeignStructuredUpload mw.ForeignStructuredUpload} + * with the {@link #cfg-target target} specified in config. + * + * @protected + * @return {mw.Upload} + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.createUpload = function () { + return new mw.ForeignStructuredUpload( this.target, { + parameters: { + errorformat: 'html', + errorlang: mw.config.get( 'wgUserLanguage' ), + errorsuselocal: 1, + formatversion: 2 + } + } ); + }; + + /* Form renderers */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.renderUploadForm = function () { + var fieldset, + layout = this; + + // These elements are filled with text in #initialize + // TODO Refactor this to be in one place + this.$ownWorkMessage = $( '

' ) + .addClass( 'mw-foreignStructuredUpload-bookletLayout-license' ); + this.$notOwnWorkMessage = $( '

' ); + this.$notOwnWorkLocal = $( '

' ); + + this.selectFileWidget = new OO.ui.SelectFileWidget( { + showDropTarget: true + } ); + this.messageLabel = new OO.ui.LabelWidget( { + label: $( '

' ).append( + this.$notOwnWorkMessage, + this.$notOwnWorkLocal + ) + } ); + this.ownWorkCheckbox = new OO.ui.CheckboxInputWidget().on( 'change', function ( on ) { + layout.messageLabel.toggle( !on ); + } ); + + fieldset = new OO.ui.FieldsetLayout(); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.selectFileWidget, { + align: 'top' + } ), + new OO.ui.FieldLayout( this.ownWorkCheckbox, { + align: 'inline', + label: $( '
' ).append( + $( '

' ).text( mw.msg( 'upload-form-label-own-work' ) ), + this.$ownWorkMessage + ) + } ), + new OO.ui.FieldLayout( this.messageLabel, { + align: 'top' + } ) + ] ); + this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + // Validation + this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) ); + this.ownWorkCheckbox.on( 'change', this.onUploadFormChange.bind( this ) ); + + this.selectFileWidget.on( 'change', function () { + var file = layout.getFile(); + + // Set the date to lastModified once we have the file + if ( layout.getDateFromLastModified( file ) !== undefined ) { + layout.dateWidget.setValue( layout.getDateFromLastModified( file ) ); + } + + // Check if we have EXIF data and set to that where available + layout.getDateFromExif( file ).done( function ( date ) { + layout.dateWidget.setValue( date ); + } ); + + layout.updateFilePreview(); + } ); + + return this.uploadForm; + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.onUploadFormChange = function () { + var file = this.selectFileWidget.getValue(), + ownWork = this.ownWorkCheckbox.isSelected(), + valid = !!file && ownWork; + this.emit( 'uploadValid', valid ); + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.renderInfoForm = function () { + var fieldset; + + this.filePreview = new OO.ui.Widget( { + classes: [ 'mw-upload-bookletLayout-filePreview' ] + } ); + this.progressBarWidget = new OO.ui.ProgressBarWidget( { + progress: 0 + } ); + this.filePreview.$element.append( this.progressBarWidget.$element ); + + this.filenameWidget = new OO.ui.TextInputWidget( { + required: true, + validate: /.+/ + } ); + this.descriptionWidget = new OO.ui.MultilineTextInputWidget( { + required: true, + validate: /\S+/, + autosize: true + } ); + this.categoriesWidget = new mw.widgets.CategoryMultiselectWidget( { + // Can't be done here because we don't know the target wiki yet... done in #initialize. + // api: new mw.ForeignApi( ... ), + $overlay: this.$overlay + } ); + this.dateWidget = new mw.widgets.DateInputWidget( { + $overlay: this.$overlay, + required: true, + mustBeBefore: moment().add( 1, 'day' ).locale( 'en' ).format( 'YYYY-MM-DD' ) // Tomorrow + } ); + + this.filenameField = new OO.ui.FieldLayout( this.filenameWidget, { + label: mw.msg( 'upload-form-label-infoform-name' ), + align: 'top', + classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ], + notices: [ mw.msg( 'upload-form-label-infoform-name-tooltip' ) ] + } ); + this.descriptionField = new OO.ui.FieldLayout( this.descriptionWidget, { + label: mw.msg( 'upload-form-label-infoform-description' ), + align: 'top', + classes: [ 'mw-foreignStructuredUploa-bookletLayout-small-notice' ], + notices: [ mw.msg( 'upload-form-label-infoform-description-tooltip' ) ] + } ); + this.categoriesField = new OO.ui.FieldLayout( this.categoriesWidget, { + label: mw.msg( 'upload-form-label-infoform-categories' ), + align: 'top' + } ); + this.dateField = new OO.ui.FieldLayout( this.dateWidget, { + label: mw.msg( 'upload-form-label-infoform-date' ), + align: 'top' + } ); + + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-infoform-title' ) + } ); + fieldset.addItems( [ + this.filenameField, + this.descriptionField, + this.categoriesField, + this.dateField + ] ); + this.infoForm = new OO.ui.FormLayout( { + classes: [ 'mw-upload-bookletLayout-infoForm' ], + items: [ this.filePreview, fieldset ] + } ); + + // Validation + this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.dateWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + + this.on( 'fileUploadProgress', function ( progress ) { + this.progressBarWidget.setProgress( progress * 100 ); + }.bind( this ) ); + + return this.infoForm; + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.onInfoFormChange = function () { + var layout = this, + validityPromises = []; + + validityPromises.push( this.filenameWidget.getValidity() ); + if ( this.descriptionField.isVisible() ) { + validityPromises.push( this.descriptionWidget.getValidity() ); + } + if ( this.dateField.isVisible() ) { + validityPromises.push( this.dateWidget.getValidity() ); + } + + $.when.apply( $, validityPromises ).done( function () { + layout.emit( 'infoValid', true ); + } ).fail( function () { + layout.emit( 'infoValid', false ); + } ); + }; + + /** + * @param {mw.Title} filename + * @return {jQuery.Promise} Resolves (on success) or rejects with OO.ui.Error + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.validateFilename = function ( filename ) { + return ( new mw.Api() ).get( { + action: 'query', + prop: 'info', + titles: filename.getPrefixedDb(), + formatversion: 2 + } ).then( + function ( result ) { + // if the file already exists, reject right away, before + // ever firing finishStashUpload() + if ( !result.query.pages[ 0 ].missing ) { + return $.Deferred().reject( new OO.ui.Error( + $( '

' ).msg( 'fileexists', filename.getPrefixedDb() ), + { recoverable: false } + ) ); + } + }, + function () { + // API call failed - this could be a connection hiccup... + // Let's just ignore this validation step and turn this + // failure into a successful resolve ;) + return $.Deferred().resolve(); + } + ); + }; + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.saveFile = function () { + var title = mw.Title.newFromText( + this.getFilename(), + mw.config.get( 'wgNamespaceIds' ).file + ); + + return this.uploadPromise + .then( this.validateFilename.bind( this, title ) ) + .then( mw.ForeignStructuredUpload.BookletLayout.parent.prototype.saveFile.bind( this ) ); + }; + + /* Getters */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getText = function () { + var language = mw.config.get( 'wgContentLanguage' ); + this.upload.clearDescriptions(); + this.upload.addDescription( language, this.descriptionWidget.getValue() ); + this.upload.setDate( this.dateWidget.getValue() ); + this.upload.clearCategories(); + this.upload.addCategories( this.categoriesWidget.getItemsData() ); + return this.upload.getText(); + }; + + /** + * Get original date from EXIF data + * + * @param {Object} file + * @return {jQuery.Promise} Promise resolved with the EXIF date + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromExif = function ( file ) { + var fileReader, + deferred = $.Deferred(); + + if ( file && file.type === 'image/jpeg' ) { + fileReader = new FileReader(); + fileReader.onload = function () { + var fileStr, arr, i, metadata, + jpegmeta = mw.loader.require( 'mediawiki.libs.jpegmeta' ); + + if ( typeof fileReader.result === 'string' ) { + fileStr = fileReader.result; + } else { + // Array buffer; convert to binary string for the library. + arr = new Uint8Array( fileReader.result ); + fileStr = ''; + for ( i = 0; i < arr.byteLength; i++ ) { + fileStr += String.fromCharCode( arr[ i ] ); + } + } + + try { + metadata = jpegmeta( fileStr, file.name ); + } catch ( e ) { + metadata = null; + } + + if ( metadata !== null && metadata.exif !== undefined && metadata.exif.DateTimeOriginal ) { + deferred.resolve( moment( metadata.exif.DateTimeOriginal, 'YYYY:MM:DD' ).format( 'YYYY-MM-DD' ) ); + } else { + deferred.reject(); + } + }; + + if ( 'readAsBinaryString' in fileReader ) { + fileReader.readAsBinaryString( file ); + } else if ( 'readAsArrayBuffer' in fileReader ) { + fileReader.readAsArrayBuffer( file ); + } else { + // We should never get here + deferred.reject(); + throw new Error( 'Cannot read thumbnail as binary string or array buffer.' ); + } + } + + return deferred.promise(); + }; + + /** + * Get last modified date from file + * + * @param {Object} file + * @return {Object} Last modified date from file + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.getDateFromLastModified = function ( file ) { + if ( file && file.lastModified ) { + return moment( file.lastModified ).format( 'YYYY-MM-DD' ); + } + }; + + /* Setters */ + + /** + * @inheritdoc + */ + mw.ForeignStructuredUpload.BookletLayout.prototype.clear = function () { + mw.ForeignStructuredUpload.BookletLayout.parent.prototype.clear.call( this ); + + this.ownWorkCheckbox.setSelected( false ); + this.categoriesWidget.setItemsFromData( [] ); + this.dateWidget.setValue( '' ).setValidityFlag( true ); + }; + +}( jQuery, mediaWiki ) ); diff --git a/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.less b/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.less new file mode 100644 index 0000000000..24ca434c06 --- /dev/null +++ b/resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.less @@ -0,0 +1,19 @@ +.mw-foreignStructuredUpload-bookletLayout-license { + font-size: 90%; + line-height: 1.4em; + color: #54595d; +} + +.mw-foreignStructuredUploa-bookletLayout-small-notice { + .oo-ui-fieldLayout-messages-notice { + .oo-ui-iconWidget { + display: none; + } + + .oo-ui-labelWidget { + line-height: 1.2em; + font-size: 0.9em; + color: #54595d; + } + } +} diff --git a/resources/src/mediawiki.Title/Title.js b/resources/src/mediawiki.Title/Title.js new file mode 100644 index 0000000000..2b76187359 --- /dev/null +++ b/resources/src/mediawiki.Title/Title.js @@ -0,0 +1,964 @@ +/*! + * @author Neil Kandalgaonkar, 2010 + * @author Timo Tijhof + * @since 1.18 + */ + +( function ( mw, $ ) { + /** + * Parse titles into an object structure. Note that when using the constructor + * directly, passing invalid titles will result in an exception. Use #newFromText to use the + * logic directly and get null for invalid titles which is easier to work with. + * + * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace + * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior, + * use #makeTitle. Compare: + * + * new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText(); // => 'Template:Foo' + * + * new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo' + * mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText(); // => 'Template:Category:Foo' + * + * new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo' + * mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText(); // => 'Template:Template:Foo' + * + * @class mw.Title + */ + + /* Private members */ + + var + mwString = require( 'mediawiki.String' ), + + namespaceIds = mw.config.get( 'wgNamespaceIds' ), + + /** + * @private + * @static + * @property NS_MAIN + */ + NS_MAIN = namespaceIds[ '' ], + + /** + * @private + * @static + * @property NS_TALK + */ + NS_TALK = namespaceIds.talk, + + /** + * @private + * @static + * @property NS_SPECIAL + */ + NS_SPECIAL = namespaceIds.special, + + /** + * @private + * @static + * @property NS_MEDIA + */ + NS_MEDIA = namespaceIds.media, + + /** + * @private + * @static + * @property NS_FILE + */ + NS_FILE = namespaceIds.file, + + /** + * @private + * @static + * @property FILENAME_MAX_BYTES + */ + FILENAME_MAX_BYTES = 240, + + /** + * @private + * @static + * @property TITLE_MAX_BYTES + */ + TITLE_MAX_BYTES = 255, + + /** + * Get the namespace id from a namespace name (either from the localized, canonical or alias + * name). + * + * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or + * even 'Bild'. + * + * @private + * @static + * @method getNsIdByName + * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored) + * @return {number|boolean} Namespace id or boolean false + */ + getNsIdByName = function ( ns ) { + var id; + + // Don't cast non-strings to strings, because null or undefined should not result in + // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki) + // Also, toLowerCase throws exception on null/undefined, because it is a String method. + if ( typeof ns !== 'string' ) { + return false; + } + // TODO: Should just use local var namespaceIds here but it + // breaks test which modify the config + id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ]; + if ( id === undefined ) { + return false; + } + return id; + }, + + /** + * @private + * @method getNamespacePrefix_ + * @param {number} namespace + * @return {string} + */ + getNamespacePrefix = function ( namespace ) { + return namespace === NS_MAIN ? + '' : + ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' ); + }, + + rUnderscoreTrim = /^_+|_+$/g, + + rSplit = /^(.+?)_*:_*(.*)$/, + + // See MediaWikiTitleCodec.php#getTitleInvalidRegex + rInvalid = new RegExp( + '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' + + // URL percent encoding sequences interfere with the ability + // to round-trip titles -- you can't link to them consistently. + '|%[0-9A-Fa-f]{2}' + + // XML/HTML character references produce similar issues. + '|&[A-Za-z0-9\u0080-\uFFFF]+;' + + '|&#[0-9]+;' + + '|&#x[0-9A-Fa-f]+;' + ), + + // From MediaWikiTitleCodec::splitTitleString() in PHP + // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included. + rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g, + + // From MediaWikiTitleCodec::splitTitleString() in PHP + rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g, + + /** + * Slightly modified from Flinfo. Credit goes to Lupo and Flominator. + * @private + * @static + * @property sanitationRules + */ + sanitationRules = [ + // "signature" + { + pattern: /~{3}/g, + replace: '', + generalRule: true + }, + // control characters + { + // eslint-disable-next-line no-control-regex + pattern: /[\x00-\x1f\x7f]/g, + replace: '', + generalRule: true + }, + // URL encoding (possibly) + { + pattern: /%([0-9A-Fa-f]{2})/g, + replace: '% $1', + generalRule: true + }, + // HTML-character-entities + { + pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g, + replace: '& $1', + generalRule: true + }, + // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/]) + { + pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ), + replace: '-', + fileRule: true + }, + // brackets, greater than + { + pattern: /[}\]>]/g, + replace: ')', + generalRule: true + }, + // brackets, lower than + { + pattern: /[{[<]/g, + replace: '(', + generalRule: true + }, + // everything that wasn't covered yet + { + pattern: new RegExp( rInvalid.source, 'g' ), + replace: '-', + generalRule: true + }, + // directory structures + { + pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g, + replace: '', + generalRule: true + } + ], + + /** + * Internal helper for #constructor and #newFromText. + * + * Based on Title.php#secureAndSplit + * + * @private + * @static + * @method parse + * @param {string} title + * @param {number} [defaultNamespace=NS_MAIN] + * @return {Object|boolean} + */ + parse = function ( title, defaultNamespace ) { + var namespace, m, id, i, fragment, ext; + + namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace; + + title = title + // Strip Unicode bidi override characters + .replace( rUnicodeBidi, '' ) + // Normalise whitespace to underscores and remove duplicates + .replace( rWhitespace, '_' ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + + // Process initial colon + if ( title !== '' && title[ 0 ] === ':' ) { + // Initial colon means main namespace instead of specified default + namespace = NS_MAIN; + title = title + // Strip colon + .slice( 1 ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + } + + if ( title === '' ) { + return false; + } + + // Process namespace prefix (if any) + m = title.match( rSplit ); + if ( m ) { + id = getNsIdByName( m[ 1 ] ); + if ( id !== false ) { + // Ordinary namespace + namespace = id; + title = m[ 2 ]; + + // For Talk:X pages, make sure X has no "namespace" prefix + if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) { + // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x) + if ( getNsIdByName( m[ 1 ] ) !== false ) { + return false; + } + } + } + } + + // Process fragment + i = title.indexOf( '#' ); + if ( i === -1 ) { + fragment = null; + } else { + fragment = title + // Get segment starting after the hash + .slice( i + 1 ) + // Convert to text + // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo") + .replace( /_/g, ' ' ); + + title = title + // Strip hash + .slice( 0, i ) + // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux") + .replace( rUnderscoreTrim, '' ); + } + + // Reject illegal characters + if ( title.match( rInvalid ) ) { + return false; + } + + // Disallow titles that browsers or servers might resolve as directory navigation + if ( + title.indexOf( '.' ) !== -1 && ( + title === '.' || title === '..' || + title.indexOf( './' ) === 0 || + title.indexOf( '../' ) === 0 || + title.indexOf( '/./' ) !== -1 || + title.indexOf( '/../' ) !== -1 || + title.slice( -2 ) === '/.' || + title.slice( -3 ) === '/..' + ) + ) { + return false; + } + + // Disallow magic tilde sequence + if ( title.indexOf( '~~~' ) !== -1 ) { + return false; + } + + // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field) + // Except for special pages, e.g. [[Special:Block/Long name]] + // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should + // be less than 512 bytes. + if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) { + return false; + } + + // Can't make a link to a namespace alone. + if ( title === '' && namespace !== NS_MAIN ) { + return false; + } + + // Any remaining initial :s are illegal. + if ( title[ 0 ] === ':' ) { + return false; + } + + // For backwards-compatibility with old mw.Title, we separate the extension from the + // rest of the title. + i = title.lastIndexOf( '.' ); + if ( i === -1 || title.length <= i + 1 ) { + // Extensions are the non-empty segment after the last dot + ext = null; + } else { + ext = title.slice( i + 1 ); + title = title.slice( 0, i ); + } + + return { + namespace: namespace, + title: title, + ext: ext, + fragment: fragment + }; + }, + + /** + * Convert db-key to readable text. + * + * @private + * @static + * @method text + * @param {string} s + * @return {string} + */ + text = function ( s ) { + if ( s !== null && s !== undefined ) { + return s.replace( /_/g, ' ' ); + } else { + return ''; + } + }, + + /** + * Sanitizes a string based on a rule set and a filter + * + * @private + * @static + * @method sanitize + * @param {string} s + * @param {Array} filter + * @return {string} + */ + sanitize = function ( s, filter ) { + var i, ruleLength, rule, m, filterLength, + rules = sanitationRules; + + for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) { + rule = rules[ i ]; + for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) { + if ( rule[ filter[ m ] ] ) { + s = s.replace( rule.pattern, rule.replace ); + } + } + } + return s; + }, + + /** + * Cuts a string to a specific byte length, assuming UTF-8 + * or less, if the last character is a multi-byte one + * + * @private + * @static + * @method trimToByteLength + * @param {string} s + * @param {number} length + * @return {string} + */ + trimToByteLength = function ( s, length ) { + return mwString.trimByteLength( '', s, length ).newVal; + }, + + /** + * Cuts a file name to a specific byte length + * + * @private + * @static + * @method trimFileNameToByteLength + * @param {string} name without extension + * @param {string} extension file extension + * @return {string} The full name, including extension + */ + trimFileNameToByteLength = function ( name, extension ) { + // There is a special byte limit for file names and ... remember the dot + return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension; + }; + + /** + * @method constructor + * @param {string} title Title of the page. If no second argument given, + * this will be searched for a namespace + * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title + * @throws {Error} When the title is invalid + */ + function Title( title, namespace ) { + var parsed = parse( title, namespace ); + if ( !parsed ) { + throw new Error( 'Unable to parse title' ); + } + + this.namespace = parsed.namespace; + this.title = parsed.title; + this.ext = parsed.ext; + this.fragment = parsed.fragment; + } + + /* Static members */ + + /** + * Constructor for Title objects with a null return instead of an exception for invalid titles. + * + * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace + * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for + * details. + * + * @static + * @param {string} title + * @param {number} [namespace=NS_MAIN] Default namespace + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.newFromText = function ( title, namespace ) { + var t, parsed = parse( title, namespace ); + if ( !parsed ) { + return null; + } + + t = Object.create( Title.prototype ); + t.namespace = parsed.namespace; + t.title = parsed.title; + t.ext = parsed.ext; + t.fragment = parsed.fragment; + + return t; + }; + + /** + * Constructor for Title objects with predefined namespace. + * + * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be + * overridden by a namespace prefix in `title`. See #constructor for details about this behavior. + * + * The single exception to this is when `namespace` is 0, indicating the main namespace. The + * function behaves like #newFromText in that case. + * + * @static + * @param {number} namespace Namespace to use for the title + * @param {string} title + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.makeTitle = function ( namespace, title ) { + return mw.Title.newFromText( getNamespacePrefix( namespace ) + title ); + }; + + /** + * Constructor for Title objects from user input altering that input to + * produce a title that MediaWiki will accept as legal + * + * @static + * @param {string} title + * @param {number} [defaultNamespace=NS_MAIN] + * If given, will used as default namespace for the given title. + * @param {Object} [options] additional options + * @param {boolean} [options.forUploading=true] + * Makes sure that a file is uploadable under the title returned. + * There are pages in the file namespace under which file upload is impossible. + * Automatically assumed if the title is created in the Media namespace. + * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title + */ + Title.newFromUserInput = function ( title, defaultNamespace, options ) { + var namespace, m, id, ext, parts; + + // defaultNamespace is optional; check whether options moves up + if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) { + options = defaultNamespace; + defaultNamespace = undefined; + } + + // merge options into defaults + options = $.extend( { + forUploading: true + }, options ); + + namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace; + + // Normalise additional whitespace + title = title.replace( /\s/g, ' ' ).trim(); + + // Process initial colon + if ( title !== '' && title[ 0 ] === ':' ) { + // Initial colon means main namespace instead of specified default + namespace = NS_MAIN; + title = title + // Strip colon + .substr( 1 ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + } + + // Process namespace prefix (if any) + m = title.match( rSplit ); + if ( m ) { + id = getNsIdByName( m[ 1 ] ); + if ( id !== false ) { + // Ordinary namespace + namespace = id; + title = m[ 2 ]; + } + } + + if ( + namespace === NS_MEDIA || + ( options.forUploading && ( namespace === NS_FILE ) ) + ) { + + title = sanitize( title, [ 'generalRule', 'fileRule' ] ); + + // Operate on the file extension + // Although it is possible having spaces between the name and the ".ext" this isn't nice for + // operating systems hiding file extensions -> strip them later on + parts = title.split( '.' ); + + if ( parts.length > 1 ) { + + // Get the last part, which is supposed to be the file extension + ext = parts.pop(); + + // Remove whitespace of the name part (that W/O extension) + title = parts.join( '.' ).trim(); + + // Cut, if too long and append file extension + title = trimFileNameToByteLength( title, ext ); + + } else { + + // Missing file extension + title = parts.join( '.' ).trim(); + + // Name has no file extension and a fallback wasn't provided either + return null; + } + } else { + + title = sanitize( title, [ 'generalRule' ] ); + + // Cut titles exceeding the TITLE_MAX_BYTES byte size limit + // (size of underlying database field) + if ( namespace !== NS_SPECIAL ) { + title = trimToByteLength( title, TITLE_MAX_BYTES ); + } + } + + // Any remaining initial :s are illegal. + title = title.replace( /^:+/, '' ); + + return Title.newFromText( title, namespace ); + }; + + /** + * Sanitizes a file name as supplied by the user, originating in the user's file system + * so it is most likely a valid MediaWiki title and file name after processing. + * Returns null on fatal errors. + * + * @static + * @param {string} uncleanName The unclean file name including file extension but + * without namespace + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.newFromFileName = function ( uncleanName ) { + + return Title.newFromUserInput( 'File:' + uncleanName, { + forUploading: true + } ); + }; + + /** + * Get the file title from an image element + * + * var title = mw.Title.newFromImg( $( 'img:first' ) ); + * + * @static + * @param {HTMLElement|jQuery} img The image to use as a base + * @return {mw.Title|null} The file title or null if unsuccessful + */ + Title.newFromImg = function ( img ) { + var matches, i, regex, src, decodedSrc, + + // thumb.php-generated thumbnails + thumbPhpRegex = /thumb\.php/, + regexes = [ + // Thumbnails + /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/, + + // Full size images + /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/, + + // Thumbnails in non-hashed upload directories + /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/, + + // Full-size images in non-hashed upload directories + /\/([^\s/]+)$/ + ], + + recount = regexes.length; + + src = img.jquery ? img[ 0 ].src : img.src; + + matches = src.match( thumbPhpRegex ); + + if ( matches ) { + return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) ); + } + + decodedSrc = decodeURIComponent( src ); + + for ( i = 0; i < recount; i++ ) { + regex = regexes[ i ]; + matches = decodedSrc.match( regex ); + + if ( matches && matches[ 1 ] ) { + return mw.Title.newFromText( 'File:' + matches[ 1 ] ); + } + } + + return null; + }; + + /** + * Whether this title exists on the wiki. + * + * @static + * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title + * @return {boolean|null} Boolean if the information is available, otherwise null + */ + Title.exists = function ( title ) { + var match, + obj = Title.exist.pages; + + if ( typeof title === 'string' ) { + match = obj[ title ]; + } else if ( title instanceof Title ) { + match = obj[ title.toString() ]; + } else { + throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' ); + } + + if ( typeof match !== 'boolean' ) { + return null; + } + + return match; + }; + + /** + * Store page existence + * + * @static + * @property {Object} exist + * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist. + * + * @property {Function} exist.set The setter function. + * + * Example to declare existing titles: + * + * Title.exist.set( ['User:John_Doe', ...] ); + * + * Example to declare titles nonexistent: + * + * Title.exist.set( ['File:Foo_bar.jpg', ...], false ); + * + * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form + * @property {boolean} [exist.set.state=true] State of the given titles + * @return {boolean} + */ + Title.exist = { + pages: {}, + + set: function ( titles, state ) { + var i, len, + pages = this.pages; + + titles = Array.isArray( titles ) ? titles : [ titles ]; + state = state === undefined ? true : !!state; + + for ( i = 0, len = titles.length; i < len; i++ ) { + pages[ titles[ i ] ] = state; + } + return true; + } + }; + + /** + * Normalize a file extension to the common form, making it lowercase and checking some synonyms, + * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded. + * Keep in sync with File::normalizeExtension() in PHP. + * + * @param {string} extension File extension (without the leading dot) + * @return {string} File extension in canonical form + */ + Title.normalizeExtension = function ( extension ) { + var + lower = extension.toLowerCase(), + squish = { + htm: 'html', + jpeg: 'jpg', + mpeg: 'mpg', + tiff: 'tif', + ogv: 'ogg' + }; + if ( squish.hasOwnProperty( lower ) ) { + return squish[ lower ]; + } else if ( /^[0-9a-z]+$/.test( lower ) ) { + return lower; + } else { + return ''; + } + }; + + /* Public members */ + + Title.prototype = { + constructor: Title, + + /** + * Get the namespace number + * + * Example: 6 for "File:Example_image.svg". + * + * @return {number} + */ + getNamespaceId: function () { + return this.namespace; + }, + + /** + * Get the namespace prefix (in the content language) + * + * Example: "File:" for "File:Example_image.svg". + * In #NS_MAIN this is '', otherwise namespace name plus ':' + * + * @return {string} + */ + getNamespacePrefix: function () { + return getNamespacePrefix( this.namespace ); + }, + + /** + * Get the page name without extension or namespace prefix + * + * Example: "Example_image" for "File:Example_image.svg". + * + * For the page title (full page name without namespace prefix), see #getMain. + * + * @return {string} + */ + getName: function () { + if ( + $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 || + !this.title.length + ) { + return this.title; + } + // PHP's strtoupper differs from String.toUpperCase in a number of cases + // Bug: T147646 + return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 ); + }, + + /** + * Get the page name (transformed by #text) + * + * Example: "Example image" for "File:Example_image.svg". + * + * For the page title (full page name without namespace prefix), see #getMainText. + * + * @return {string} + */ + getNameText: function () { + return text( this.getName() ); + }, + + /** + * Get the extension of the page name (if any) + * + * @return {string|null} Name extension or null if there is none + */ + getExtension: function () { + return this.ext; + }, + + /** + * Shortcut for appendable string to form the main page name. + * + * Returns a string like ".json", or "" if no extension. + * + * @return {string} + */ + getDotExtension: function () { + return this.ext === null ? '' : '.' + this.ext; + }, + + /** + * Get the main page name + * + * Example: "Example_image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getMain: function () { + return this.getName() + this.getDotExtension(); + }, + + /** + * Get the main page name (transformed by #text) + * + * Example: "Example image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getMainText: function () { + return text( this.getMain() ); + }, + + /** + * Get the full page name + * + * Example: "File:Example_image.svg". + * Most useful for API calls, anything that must identify the "title". + * + * @return {string} + */ + getPrefixedDb: function () { + return this.getNamespacePrefix() + this.getMain(); + }, + + /** + * Get the full page name (transformed by #text) + * + * Example: "File:Example image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getPrefixedText: function () { + return text( this.getPrefixedDb() ); + }, + + /** + * Get the page name relative to a namespace + * + * Example: + * + * - "Foo:Bar" relative to the Foo namespace becomes "Bar". + * - "Bar" relative to any non-main namespace becomes ":Bar". + * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar". + * + * @param {number} namespace The namespace to be relative to + * @return {string} + */ + getRelativeText: function ( namespace ) { + if ( this.getNamespaceId() === namespace ) { + return this.getMainText(); + } else if ( this.getNamespaceId() === NS_MAIN ) { + return ':' + this.getPrefixedText(); + } else { + return this.getPrefixedText(); + } + }, + + /** + * Get the fragment (if any). + * + * Note that this method (by design) does not include the hash character and + * the value is not url encoded. + * + * @return {string|null} + */ + getFragment: function () { + return this.fragment; + }, + + /** + * Get the URL to this title + * + * @see mw.util#getUrl + * @param {Object} [params] A mapping of query parameter names to values, + * e.g. `{ action: 'edit' }`. + * @return {string} + */ + getUrl: function ( params ) { + var fragment = this.getFragment(); + if ( fragment ) { + return mw.util.getUrl( this.toString() + '#' + fragment, params ); + } else { + return mw.util.getUrl( this.toString(), params ); + } + }, + + /** + * Whether this title exists on the wiki. + * + * @see #static-method-exists + * @return {boolean|null} Boolean if the information is available, otherwise null + */ + exists: function () { + return Title.exists( this ); + } + }; + + /** + * @alias #getPrefixedDb + * @method + */ + Title.prototype.toString = Title.prototype.getPrefixedDb; + + /** + * @alias #getPrefixedText + * @method + */ + Title.prototype.toText = Title.prototype.getPrefixedText; + + // Expose + mw.Title = Title; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.Title/phpCharToUpper.js b/resources/src/mediawiki.Title/phpCharToUpper.js new file mode 100644 index 0000000000..2b39c9ab29 --- /dev/null +++ b/resources/src/mediawiki.Title/phpCharToUpper.js @@ -0,0 +1,255 @@ +// This file can't be parsed by JSDuck due to . +// (It is excluded in jsduck.json.) +// ESLint suggests unquoting some object keys, which would render the file unparseable by Opera 12. +/* eslint-disable quote-props */ +( function ( mw ) { + var toUpperMapping = { + 'ß': 'ß', + 'ʼn': 'ʼn', + 'Dž': 'Dž', + 'dž': 'Dž', + 'Lj': 'Lj', + 'lj': 'Lj', + 'Nj': 'Nj', + 'nj': 'Nj', + 'ǰ': 'ǰ', + 'Dz': 'Dz', + 'dz': 'Dz', + 'ʝ': 'Ʝ', + 'ͅ': 'ͅ', + 'ΐ': 'ΐ', + 'ΰ': 'ΰ', + 'և': 'և', + 'ᏸ': 'Ᏸ', + 'ᏹ': 'Ᏹ', + 'ᏺ': 'Ᏺ', + 'ᏻ': 'Ᏻ', + 'ᏼ': 'Ᏼ', + 'ᏽ': 'Ᏽ', + 'ẖ': 'ẖ', + 'ẗ': 'ẗ', + 'ẘ': 'ẘ', + 'ẙ': 'ẙ', + 'ẚ': 'ẚ', + 'ὐ': 'ὐ', + 'ὒ': 'ὒ', + 'ὔ': 'ὔ', + 'ὖ': 'ὖ', + 'ᾀ': 'ᾈ', + 'ᾁ': 'ᾉ', + 'ᾂ': 'ᾊ', + 'ᾃ': 'ᾋ', + 'ᾄ': 'ᾌ', + 'ᾅ': 'ᾍ', + 'ᾆ': 'ᾎ', + 'ᾇ': 'ᾏ', + 'ᾈ': 'ᾈ', + 'ᾉ': 'ᾉ', + 'ᾊ': 'ᾊ', + 'ᾋ': 'ᾋ', + 'ᾌ': 'ᾌ', + 'ᾍ': 'ᾍ', + 'ᾎ': 'ᾎ', + 'ᾏ': 'ᾏ', + 'ᾐ': 'ᾘ', + 'ᾑ': 'ᾙ', + 'ᾒ': 'ᾚ', + 'ᾓ': 'ᾛ', + 'ᾔ': 'ᾜ', + 'ᾕ': 'ᾝ', + 'ᾖ': 'ᾞ', + 'ᾗ': 'ᾟ', + 'ᾘ': 'ᾘ', + 'ᾙ': 'ᾙ', + 'ᾚ': 'ᾚ', + 'ᾛ': 'ᾛ', + 'ᾜ': 'ᾜ', + 'ᾝ': 'ᾝ', + 'ᾞ': 'ᾞ', + 'ᾟ': 'ᾟ', + 'ᾠ': 'ᾨ', + 'ᾡ': 'ᾩ', + 'ᾢ': 'ᾪ', + 'ᾣ': 'ᾫ', + 'ᾤ': 'ᾬ', + 'ᾥ': 'ᾭ', + 'ᾦ': 'ᾮ', + 'ᾧ': 'ᾯ', + 'ᾨ': 'ᾨ', + 'ᾩ': 'ᾩ', + 'ᾪ': 'ᾪ', + 'ᾫ': 'ᾫ', + 'ᾬ': 'ᾬ', + 'ᾭ': 'ᾭ', + 'ᾮ': 'ᾮ', + 'ᾯ': 'ᾯ', + 'ᾲ': 'ᾲ', + 'ᾳ': 'ᾼ', + 'ᾴ': 'ᾴ', + 'ᾶ': 'ᾶ', + 'ᾷ': 'ᾷ', + 'ᾼ': 'ᾼ', + 'ῂ': 'ῂ', + 'ῃ': 'ῌ', + 'ῄ': 'ῄ', + 'ῆ': 'ῆ', + 'ῇ': 'ῇ', + 'ῌ': 'ῌ', + 'ῒ': 'ῒ', + 'ΐ': 'ΐ', + 'ῖ': 'ῖ', + 'ῗ': 'ῗ', + 'ῢ': 'ῢ', + 'ΰ': 'ΰ', + 'ῤ': 'ῤ', + 'ῦ': 'ῦ', + 'ῧ': 'ῧ', + 'ῲ': 'ῲ', + 'ῳ': 'ῼ', + 'ῴ': 'ῴ', + 'ῶ': 'ῶ', + 'ῷ': 'ῷ', + 'ῼ': 'ῼ', + 'ⅰ': 'ⅰ', + 'ⅱ': 'ⅱ', + 'ⅲ': 'ⅲ', + 'ⅳ': 'ⅳ', + 'ⅴ': 'ⅴ', + 'ⅵ': 'ⅵ', + 'ⅶ': 'ⅶ', + 'ⅷ': 'ⅷ', + 'ⅸ': 'ⅸ', + 'ⅹ': 'ⅹ', + 'ⅺ': 'ⅺ', + 'ⅻ': 'ⅻ', + 'ⅼ': 'ⅼ', + 'ⅽ': 'ⅽ', + 'ⅾ': 'ⅾ', + 'ⅿ': 'ⅿ', + 'ⓐ': 'ⓐ', + 'ⓑ': 'ⓑ', + 'ⓒ': 'ⓒ', + 'ⓓ': 'ⓓ', + 'ⓔ': 'ⓔ', + 'ⓕ': 'ⓕ', + 'ⓖ': 'ⓖ', + 'ⓗ': 'ⓗ', + 'ⓘ': 'ⓘ', + 'ⓙ': 'ⓙ', + 'ⓚ': 'ⓚ', + 'ⓛ': 'ⓛ', + 'ⓜ': 'ⓜ', + 'ⓝ': 'ⓝ', + 'ⓞ': 'ⓞ', + 'ⓟ': 'ⓟ', + 'ⓠ': 'ⓠ', + 'ⓡ': 'ⓡ', + 'ⓢ': 'ⓢ', + 'ⓣ': 'ⓣ', + 'ⓤ': 'ⓤ', + 'ⓥ': 'ⓥ', + 'ⓦ': 'ⓦ', + 'ⓧ': 'ⓧ', + 'ⓨ': 'ⓨ', + 'ⓩ': 'ⓩ', + 'ꞵ': 'Ꞵ', + 'ꞷ': 'Ꞷ', + 'ꭓ': 'Ꭓ', + 'ꭰ': 'Ꭰ', + 'ꭱ': 'Ꭱ', + 'ꭲ': 'Ꭲ', + 'ꭳ': 'Ꭳ', + 'ꭴ': 'Ꭴ', + 'ꭵ': 'Ꭵ', + 'ꭶ': 'Ꭶ', + 'ꭷ': 'Ꭷ', + 'ꭸ': 'Ꭸ', + 'ꭹ': 'Ꭹ', + 'ꭺ': 'Ꭺ', + 'ꭻ': 'Ꭻ', + 'ꭼ': 'Ꭼ', + 'ꭽ': 'Ꭽ', + 'ꭾ': 'Ꭾ', + 'ꭿ': 'Ꭿ', + 'ꮀ': 'Ꮀ', + 'ꮁ': 'Ꮁ', + 'ꮂ': 'Ꮂ', + 'ꮃ': 'Ꮃ', + 'ꮄ': 'Ꮄ', + 'ꮅ': 'Ꮅ', + 'ꮆ': 'Ꮆ', + 'ꮇ': 'Ꮇ', + 'ꮈ': 'Ꮈ', + 'ꮉ': 'Ꮉ', + 'ꮊ': 'Ꮊ', + 'ꮋ': 'Ꮋ', + 'ꮌ': 'Ꮌ', + 'ꮍ': 'Ꮍ', + 'ꮎ': 'Ꮎ', + 'ꮏ': 'Ꮏ', + 'ꮐ': 'Ꮐ', + 'ꮑ': 'Ꮑ', + 'ꮒ': 'Ꮒ', + 'ꮓ': 'Ꮓ', + 'ꮔ': 'Ꮔ', + 'ꮕ': 'Ꮕ', + 'ꮖ': 'Ꮖ', + 'ꮗ': 'Ꮗ', + 'ꮘ': 'Ꮘ', + 'ꮙ': 'Ꮙ', + 'ꮚ': 'Ꮚ', + 'ꮛ': 'Ꮛ', + 'ꮜ': 'Ꮜ', + 'ꮝ': 'Ꮝ', + 'ꮞ': 'Ꮞ', + 'ꮟ': 'Ꮟ', + 'ꮠ': 'Ꮠ', + 'ꮡ': 'Ꮡ', + 'ꮢ': 'Ꮢ', + 'ꮣ': 'Ꮣ', + 'ꮤ': 'Ꮤ', + 'ꮥ': 'Ꮥ', + 'ꮦ': 'Ꮦ', + 'ꮧ': 'Ꮧ', + 'ꮨ': 'Ꮨ', + 'ꮩ': 'Ꮩ', + 'ꮪ': 'Ꮪ', + 'ꮫ': 'Ꮫ', + 'ꮬ': 'Ꮬ', + 'ꮭ': 'Ꮭ', + 'ꮮ': 'Ꮮ', + 'ꮯ': 'Ꮯ', + 'ꮰ': 'Ꮰ', + 'ꮱ': 'Ꮱ', + 'ꮲ': 'Ꮲ', + 'ꮳ': 'Ꮳ', + 'ꮴ': 'Ꮴ', + 'ꮵ': 'Ꮵ', + 'ꮶ': 'Ꮶ', + 'ꮷ': 'Ꮷ', + 'ꮸ': 'Ꮸ', + 'ꮹ': 'Ꮹ', + 'ꮺ': 'Ꮺ', + 'ꮻ': 'Ꮻ', + 'ꮼ': 'Ꮼ', + 'ꮽ': 'Ꮽ', + 'ꮾ': 'Ꮾ', + 'ꮿ': 'Ꮿ', + 'ff': 'ff', + 'fi': 'fi', + 'fl': 'fl', + 'ffi': 'ffi', + 'ffl': 'ffl', + 'ſt': 'ſt', + 'st': 'st', + 'ﬓ': 'ﬓ', + 'ﬔ': 'ﬔ', + 'ﬕ': 'ﬕ', + 'ﬖ': 'ﬖ', + 'ﬗ': 'ﬗ' + }; + mw.Title.phpCharToUpper = function ( chr ) { + var mapped = toUpperMapping[ chr ]; + return mapped || chr.toUpperCase(); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.css b/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.css new file mode 100644 index 0000000000..72ce9f0b32 --- /dev/null +++ b/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.css @@ -0,0 +1,34 @@ +.mw-upload-bookletLayout-filePreview { + width: 100%; + height: 1em; + background-color: #eaecf0; + background-size: cover; + background-position: center center; + padding: 1.5em; + margin: -1.5em; + margin-bottom: 1.5em; + position: relative; +} + +.mw-upload-bookletLayout-infoForm.mw-upload-bookletLayout-hasThumbnail .mw-upload-bookletLayout-filePreview { + height: 10em; +} + +.mw-upload-bookletLayout-filePreview p { + line-height: 1em; + margin: 0; +} + +.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget { + border: 0; + border-radius: 0; + background-color: transparent; + position: absolute; + bottom: 0; + left: 0; + right: 0; +} + +.mw-upload-bookletLayout-filePreview .oo-ui-progressBarWidget-bar { + height: 0.5em; +} diff --git a/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js b/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js new file mode 100644 index 0000000000..06788f58e8 --- /dev/null +++ b/resources/src/mediawiki.Upload.BookletLayout/BookletLayout.js @@ -0,0 +1,711 @@ +/* global moment */ +( function ( $, mw, moment ) { + + /** + * mw.Upload.BookletLayout encapsulates the process of uploading a file + * to MediaWiki using the {@link mw.Upload upload model}. + * The booklet emits events that can be used to get the stashed + * upload and the final file. It can be extended to accept + * additional fields from the user for specific scenarios like + * for Commons, or campaigns. + * + * ## Structure + * + * The {@link OO.ui.BookletLayout booklet layout} has three steps: + * + * - **Upload**: Has a {@link OO.ui.SelectFileWidget field} to get the file object. + * + * - **Information**: Has a {@link OO.ui.FormLayout form} to collect metadata. This can be + * extended. + * + * - **Insert**: Has details on how to use the file that was uploaded. + * + * Each step has a form associated with it defined in + * {@link #renderUploadForm renderUploadForm}, + * {@link #renderInfoForm renderInfoForm}, and + * {@link #renderInsertForm renderInfoForm}. The + * {@link #getFile getFile}, + * {@link #getFilename getFilename}, and + * {@link #getText getText} methods are used to get + * the information filled in these forms, required to call + * {@link mw.Upload mw.Upload}. + * + * ## Usage + * + * See the {@link mw.Upload.Dialog upload dialog}. + * + * The {@link #event-fileUploaded fileUploaded}, + * and {@link #event-fileSaved fileSaved} events can + * be used to get details of the upload. + * + * ## Extending + * + * To extend using {@link mw.Upload mw.Upload}, override + * {@link #renderInfoForm renderInfoForm} to render + * the form required for the specific use-case. Update the + * {@link #getFilename getFilename}, and + * {@link #getText getText} methods to return data + * from your newly created form. If you added new fields you'll also have + * to update the {@link #clear} method. + * + * If you plan to use a different upload model, apart from what is mentioned + * above, you'll also have to override the + * {@link #createUpload createUpload} method to + * return the new model. The {@link #saveFile saveFile}, and + * the {@link #uploadFile uploadFile} methods need to be + * overridden to use the new model and data returned from the forms. + * + * @class + * @extends OO.ui.BookletLayout + * + * @constructor + * @param {Object} config Configuration options + * @cfg {jQuery} [$overlay] Overlay to use for widgets in the booklet + * @cfg {string} [filekey] Sets the stashed file to finish uploading. Overrides most of the file selection process, and fetches a thumbnail from the server. + */ + mw.Upload.BookletLayout = function ( config ) { + // Parent constructor + mw.Upload.BookletLayout.parent.call( this, config ); + + this.$overlay = config.$overlay; + + this.filekey = config.filekey; + + this.renderUploadForm(); + this.renderInfoForm(); + this.renderInsertForm(); + + this.addPages( [ + new OO.ui.PageLayout( 'upload', { + scrollable: true, + padded: true, + content: [ this.uploadForm ] + } ), + new OO.ui.PageLayout( 'info', { + scrollable: true, + padded: true, + content: [ this.infoForm ] + } ), + new OO.ui.PageLayout( 'insert', { + scrollable: true, + padded: true, + content: [ this.insertForm ] + } ) + ] ); + }; + + /* Setup */ + + OO.inheritClass( mw.Upload.BookletLayout, OO.ui.BookletLayout ); + + /* Events */ + + /** + * Progress events for the uploaded file + * + * @event fileUploadProgress + * @param {number} progress In percentage + * @param {Object} duration Duration object from `moment.duration()` + */ + + /** + * The file has finished uploading + * + * @event fileUploaded + */ + + /** + * The file has been saved to the database + * + * @event fileSaved + * @param {Object} imageInfo See mw.Upload#getImageInfo + */ + + /** + * The upload form has changed + * + * @event uploadValid + * @param {boolean} isValid The form is valid + */ + + /** + * The info form has changed + * + * @event infoValid + * @param {boolean} isValid The form is valid + */ + + /* Properties */ + + /** + * @property {OO.ui.FormLayout} uploadForm + * The form rendered in the first step to get the file object. + * Rendered in {@link #renderUploadForm renderUploadForm}. + */ + + /** + * @property {OO.ui.FormLayout} infoForm + * The form rendered in the second step to get metadata. + * Rendered in {@link #renderInfoForm renderInfoForm} + */ + + /** + * @property {OO.ui.FormLayout} insertForm + * The form rendered in the third step to show usage + * Rendered in {@link #renderInsertForm renderInsertForm} + */ + + /* Methods */ + + /** + * Initialize for a new upload + * + * @return {jQuery.Promise} Promise resolved when everything is initialized + */ + mw.Upload.BookletLayout.prototype.initialize = function () { + var booklet = this; + + this.clear(); + this.upload = this.createUpload(); + + this.setPage( 'upload' ); + + if ( this.filekey ) { + this.setFilekey( this.filekey ); + } + + return this.upload.getApi().then( + function ( api ) { + // If the user can't upload anything, don't give them the option to. + return api.getUserInfo().then( + function ( userInfo ) { + if ( userInfo.rights.indexOf( 'upload' ) === -1 ) { + if ( mw.user.isAnon() ) { + booklet.getPage( 'upload' ).$element.msg( 'apierror-mustbeloggedin', mw.msg( 'action-upload' ) ); + } else { + booklet.getPage( 'upload' ).$element.msg( 'apierror-permissiondenied', mw.msg( 'action-upload' ) ); + } + } + return $.Deferred().resolve(); + }, + // Always resolve, never reject + function () { return $.Deferred().resolve(); } + ); + }, + function ( errorMsg ) { + booklet.getPage( 'upload' ).$element.msg( errorMsg ); + return $.Deferred().resolve(); + } + ); + }; + + /** + * Create a new upload model + * + * @protected + * @return {mw.Upload} Upload model + */ + mw.Upload.BookletLayout.prototype.createUpload = function () { + return new mw.Upload( { + parameters: { + errorformat: 'html', + errorlang: mw.config.get( 'wgUserLanguage' ), + errorsuselocal: 1, + formatversion: 2 + } + } ); + }; + + /* Uploading */ + + /** + * Uploads the file that was added in the upload form. Uses + * {@link #getFile getFile} to get the HTML5 + * file object. + * + * @protected + * @fires fileUploadProgress + * @fires fileUploaded + * @return {jQuery.Promise} + */ + mw.Upload.BookletLayout.prototype.uploadFile = function () { + var deferred = $.Deferred(), + startTime = mw.now(), + layout = this, + file = this.getFile(); + + this.setPage( 'info' ); + + if ( this.filekey ) { + if ( file === null ) { + // Someone gonna get-a hurt real bad + throw new Error( 'filekey not passed into file select widget, which is impossible. Quitting while we\'re behind.' ); + } + + // Stashed file already uploaded. + deferred.resolve(); + this.uploadPromise = deferred; + this.emit( 'fileUploaded' ); + return deferred; + } + + this.setFilename( file.name ); + + this.upload.setFile( file ); + // The original file name might contain invalid characters, so use our sanitized one + this.upload.setFilename( this.getFilename() ); + + this.uploadPromise = this.upload.uploadToStash(); + this.uploadPromise.then( function () { + deferred.resolve(); + layout.emit( 'fileUploaded' ); + }, function () { + // These errors will be thrown while the user is on the info page. + layout.getErrorMessageForStateDetails().then( function ( errorMessage ) { + deferred.reject( errorMessage ); + } ); + }, function ( progress ) { + var elapsedTime = mw.now() - startTime, + estimatedTotalTime = ( 1 / progress ) * elapsedTime, + estimatedRemainingTime = moment.duration( estimatedTotalTime - elapsedTime ); + layout.emit( 'fileUploadProgress', progress, estimatedRemainingTime ); + } ); + + // If there is an error in uploading, come back to the upload page + deferred.fail( function () { + layout.setPage( 'upload' ); + } ); + + return deferred; + }; + + /** + * Saves the stash finalizes upload. Uses + * {@link #getFilename getFilename}, and + * {@link #getText getText} to get details from + * the form. + * + * @protected + * @fires fileSaved + * @return {jQuery.Promise} Rejects the promise with an + * {@link OO.ui.Error error}, or resolves if the upload was successful. + */ + mw.Upload.BookletLayout.prototype.saveFile = function () { + var layout = this, + deferred = $.Deferred(); + + this.upload.setFilename( this.getFilename() ); + this.upload.setText( this.getText() ); + + this.uploadPromise.then( function () { + layout.upload.finishStashUpload().then( function () { + var name; + + // Normalize page name and localise the 'File:' prefix + name = new mw.Title( 'File:' + layout.upload.getFilename() ).toString(); + layout.filenameUsageWidget.setValue( '[[' + name + ']]' ); + layout.setPage( 'insert' ); + + deferred.resolve(); + layout.emit( 'fileSaved', layout.upload.getImageInfo() ); + }, function () { + layout.getErrorMessageForStateDetails().then( function ( errorMessage ) { + deferred.reject( errorMessage ); + } ); + } ); + } ); + + return deferred.promise(); + }; + + /** + * Get an error message (as OO.ui.Error object) that should be displayed to the user for current + * state and state details. + * + * @protected + * @return {jQuery.Promise} A Promise that will be resolved with an OO.ui.Error. + */ + mw.Upload.BookletLayout.prototype.getErrorMessageForStateDetails = function () { + var state = this.upload.getState(), + stateDetails = this.upload.getStateDetails(), + error = stateDetails.errors ? stateDetails.errors[ 0 ] : false, + warnings = stateDetails.upload && stateDetails.upload.warnings, + $ul = $( '

    ' ), + errorText; + + if ( state === mw.Upload.State.ERROR ) { + if ( !error ) { + if ( stateDetails.textStatus === 'timeout' ) { + // in case of $.ajax.fail(), there is no response json + errorText = mw.message( 'apierror-timeout' ).parse(); + } else if ( stateDetails.xhr && stateDetails.xhr.status === 0 ) { + // failed to even connect to server + errorText = mw.message( 'apierror-offline' ).parse(); + } else if ( stateDetails.textStatus ) { + errorText = stateDetails.textStatus; + } else { + errorText = mw.message( 'apierror-unknownerror', JSON.stringify( stateDetails ) ).parse(); + } + + // If there's an 'exception' key, this might be a timeout, or other connection problem + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).html( errorText ), + { recoverable: false } + ) ); + } + + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).html( error.html ), + { recoverable: false } + ) ); + } + + if ( state === mw.Upload.State.WARNING ) { + // We could get more than one of these errors, these are in order + // of importance. For example fixing the thumbnail like file name + // won't help the fact that the file already exists. + if ( warnings.exists !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'fileexists', 'File:' + warnings.exists ), + { recoverable: false } + ) ); + } else if ( warnings[ 'exists-normalized' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'fileexists', 'File:' + warnings[ 'exists-normalized' ] ), + { recoverable: false } + ) ); + } else if ( warnings[ 'page-exists' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'filepageexists', 'File:' + warnings[ 'page-exists' ] ), + { recoverable: false } + ) ); + } else if ( Array.isArray( warnings.duplicate ) ) { + warnings.duplicate.forEach( function ( filename ) { + var $a = $( '' ).text( filename ), + href = mw.Title.makeTitle( mw.config.get( 'wgNamespaceIds' ).file, filename ).getUrl( {} ); + + $a.attr( { href: href, target: '_blank' } ); + $ul.append( $( '

  • ' ).append( $a ) ); + } ); + + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'file-exists-duplicate', warnings.duplicate.length ).append( $ul ), + { recoverable: false } + ) ); + } else if ( warnings[ 'thumb-name' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'filename-thumb-name' ), + { recoverable: false } + ) ); + } else if ( warnings[ 'bad-prefix' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'filename-bad-prefix', warnings[ 'bad-prefix' ] ), + { recoverable: false } + ) ); + } else if ( warnings[ 'duplicate-archive' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'file-deleted-duplicate', 'File:' + warnings[ 'duplicate-archive' ] ), + { recoverable: false } + ) ); + } else if ( warnings[ 'was-deleted' ] !== undefined ) { + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'filewasdeleted', 'File:' + warnings[ 'was-deleted' ] ), + { recoverable: false } + ) ); + } else if ( warnings.badfilename !== undefined ) { + // Change the name if the current name isn't acceptable + // TODO This might not really be the best place to do this + this.setFilename( warnings.badfilename ); + return $.Deferred().resolve( new OO.ui.Error( + $( '

    ' ).msg( 'badfilename', warnings.badfilename ) + ) ); + } else { + return $.Deferred().resolve( new OO.ui.Error( + // Let's get all the help we can if we can't pin point the error + $( '

    ' ).msg( 'api-error-unknown-warning', JSON.stringify( stateDetails ) ), + { recoverable: false } + ) ); + } + } + }; + + /* Form renderers */ + + /** + * Renders and returns the upload form and sets the + * {@link #uploadForm uploadForm} property. + * + * @protected + * @fires selectFile + * @return {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderUploadForm = function () { + var fieldset, + layout = this; + + this.selectFileWidget = this.getFileWidget(); + fieldset = new OO.ui.FieldsetLayout(); + fieldset.addItems( [ this.selectFileWidget ] ); + this.uploadForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + // Validation (if the SFW is for a stashed file, this never fires) + this.selectFileWidget.on( 'change', this.onUploadFormChange.bind( this ) ); + + this.selectFileWidget.on( 'change', function () { + layout.updateFilePreview(); + } ); + + return this.uploadForm; + }; + + /** + * Gets the widget for displaying or inputting the file to upload. + * + * @return {OO.ui.SelectFileWidget|mw.widgets.StashedFileWidget} + */ + mw.Upload.BookletLayout.prototype.getFileWidget = function () { + if ( this.filekey ) { + return new mw.widgets.StashedFileWidget( { + filekey: this.filekey + } ); + } + + return new OO.ui.SelectFileWidget( { + showDropTarget: true + } ); + }; + + /** + * Updates the file preview on the info form when a file is added. + * + * @protected + */ + mw.Upload.BookletLayout.prototype.updateFilePreview = function () { + this.selectFileWidget.loadAndGetImageUrl().done( function ( url ) { + this.filePreview.$element.find( 'p' ).remove(); + this.filePreview.$element.css( 'background-image', 'url(' + url + ')' ); + this.infoForm.$element.addClass( 'mw-upload-bookletLayout-hasThumbnail' ); + }.bind( this ) ).fail( function () { + this.filePreview.$element.find( 'p' ).remove(); + if ( this.selectFileWidget.getValue() ) { + this.filePreview.$element.append( + $( '

    ' ).text( this.selectFileWidget.getValue().name ) + ); + } + this.filePreview.$element.css( 'background-image', '' ); + this.infoForm.$element.removeClass( 'mw-upload-bookletLayout-hasThumbnail' ); + }.bind( this ) ); + }; + + /** + * Handle change events to the upload form + * + * @protected + * @fires uploadValid + */ + mw.Upload.BookletLayout.prototype.onUploadFormChange = function () { + this.emit( 'uploadValid', !!this.selectFileWidget.getValue() ); + }; + + /** + * Renders and returns the information form for collecting + * metadata and sets the {@link #infoForm infoForm} + * property. + * + * @protected + * @return {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderInfoForm = function () { + var fieldset; + + this.filePreview = new OO.ui.Widget( { + classes: [ 'mw-upload-bookletLayout-filePreview' ] + } ); + this.progressBarWidget = new OO.ui.ProgressBarWidget( { + progress: 0 + } ); + this.filePreview.$element.append( this.progressBarWidget.$element ); + + this.filenameWidget = new OO.ui.TextInputWidget( { + indicator: 'required', + required: true, + validate: /.+/ + } ); + this.descriptionWidget = new OO.ui.MultilineTextInputWidget( { + indicator: 'required', + required: true, + validate: /\S+/, + autosize: true + } ); + + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-infoform-title' ) + } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.filenameWidget, { + label: mw.msg( 'upload-form-label-infoform-name' ), + align: 'top', + help: mw.msg( 'upload-form-label-infoform-name-tooltip' ) + } ), + new OO.ui.FieldLayout( this.descriptionWidget, { + label: mw.msg( 'upload-form-label-infoform-description' ), + align: 'top', + help: mw.msg( 'upload-form-label-infoform-description-tooltip' ) + } ) + ] ); + this.infoForm = new OO.ui.FormLayout( { + classes: [ 'mw-upload-bookletLayout-infoForm' ], + items: [ this.filePreview, fieldset ] + } ); + + this.on( 'fileUploadProgress', function ( progress ) { + this.progressBarWidget.setProgress( progress * 100 ); + }.bind( this ) ); + + this.filenameWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + this.descriptionWidget.on( 'change', this.onInfoFormChange.bind( this ) ); + + return this.infoForm; + }; + + /** + * Handle change events to the info form + * + * @protected + * @fires infoValid + */ + mw.Upload.BookletLayout.prototype.onInfoFormChange = function () { + var layout = this; + $.when( + this.filenameWidget.getValidity(), + this.descriptionWidget.getValidity() + ).done( function () { + layout.emit( 'infoValid', true ); + } ).fail( function () { + layout.emit( 'infoValid', false ); + } ); + }; + + /** + * Renders and returns the insert form to show file usage and + * sets the {@link #insertForm insertForm} property. + * + * @protected + * @return {OO.ui.FormLayout} + */ + mw.Upload.BookletLayout.prototype.renderInsertForm = function () { + var fieldset; + + this.filenameUsageWidget = new OO.ui.TextInputWidget(); + fieldset = new OO.ui.FieldsetLayout( { + label: mw.msg( 'upload-form-label-usage-title' ) + } ); + fieldset.addItems( [ + new OO.ui.FieldLayout( this.filenameUsageWidget, { + label: mw.msg( 'upload-form-label-usage-filename' ), + align: 'top' + } ) + ] ); + this.insertForm = new OO.ui.FormLayout( { items: [ fieldset ] } ); + + return this.insertForm; + }; + + /* Getters */ + + /** + * Gets the file object from the + * {@link #uploadForm upload form}. + * + * @protected + * @return {File|null} + */ + mw.Upload.BookletLayout.prototype.getFile = function () { + return this.selectFileWidget.getValue(); + }; + + /** + * Gets the file name from the + * {@link #infoForm information form}. + * + * @protected + * @return {string} + */ + mw.Upload.BookletLayout.prototype.getFilename = function () { + var filename = this.filenameWidget.getValue(); + if ( this.filenameExtension ) { + filename += '.' + this.filenameExtension; + } + return filename; + }; + + /** + * Prefills the {@link #infoForm information form} with the given filename. + * + * @protected + * @param {string} filename + */ + mw.Upload.BookletLayout.prototype.setFilename = function ( filename ) { + var title = mw.Title.newFromFileName( filename ); + + if ( title ) { + this.filenameWidget.setValue( title.getNameText() ); + this.filenameExtension = mw.Title.normalizeExtension( title.getExtension() ); + } else { + // Seems to happen for files with no extension, which should fail some checks anyway... + this.filenameWidget.setValue( filename ); + this.filenameExtension = null; + } + }; + + /** + * Gets the page text from the + * {@link #infoForm information form}. + * + * @protected + * @return {string} + */ + mw.Upload.BookletLayout.prototype.getText = function () { + return this.descriptionWidget.getValue(); + }; + + /* Setters */ + + /** + * Sets the file object + * + * @protected + * @param {File|null} file File to select + */ + mw.Upload.BookletLayout.prototype.setFile = function ( file ) { + this.selectFileWidget.setValue( file ); + }; + + /** + * Sets the filekey of a file already stashed on the server + * as the target of this upload operation. + * + * @protected + * @param {string} filekey + */ + mw.Upload.BookletLayout.prototype.setFilekey = function ( filekey ) { + this.upload.setFilekey( this.filekey ); + this.selectFileWidget.setValue( filekey ); + + this.onUploadFormChange(); + }; + + /** + * Clear the values of all fields + * + * @protected + */ + mw.Upload.BookletLayout.prototype.clear = function () { + this.selectFileWidget.setValue( null ); + this.progressBarWidget.setProgress( 0 ); + this.filenameWidget.setValue( null ).setValidityFlag( true ); + this.descriptionWidget.setValue( null ).setValidityFlag( true ); + this.filenameUsageWidget.setValue( null ); + }; + +}( jQuery, mediaWiki, moment ) ); diff --git a/resources/src/mediawiki.Uri/Uri.js b/resources/src/mediawiki.Uri/Uri.js new file mode 100644 index 0000000000..7f12835ec7 --- /dev/null +++ b/resources/src/mediawiki.Uri/Uri.js @@ -0,0 +1,438 @@ +/** + * Library for simple URI parsing and manipulation. + * + * Intended to be minimal, but featureful; do not expect full RFC 3986 compliance. The use cases we + * have in mind are constructing 'next page' or 'previous page' URLs, detecting whether we need to + * use cross-domain proxies for an API, constructing simple URL-based API calls, etc. Parsing here + * is regex-based, so may not work on all URIs, but is good enough for most. + * + * You can modify the properties directly, then use the #toString method to extract the full URI + * string again. Example: + * + * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' ); + * + * if ( uri.host == 'example.com' ) { + * uri.host = 'foo.example.com'; + * uri.extend( { bar: 1 } ); + * + * $( 'a#id1' ).attr( 'href', uri ); + * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2 + * + * $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) ); + * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf + * } + * + * Given a URI like + * `http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top` + * the returned object will have the following properties: + * + * protocol 'http' + * user 'usr' + * password 'pwd' + * host 'www.example.com' + * port '81' + * path '/dir/dir.2/index.htm' + * query { + * q1: '0', + * test1: null, + * test2: '', + * test3: 'value (escaped)' + * r: ['1', '2'] + * } + * fragment 'top' + * + * (N.b., 'password' is technically not allowed for HTTP URIs, but it is possible with other kinds + * of URIs.) + * + * Parsing based on parseUri 1.2.2 (c) Steven Levithan , MIT License. + * + * + * @class mw.Uri + */ + +/* eslint-disable no-use-before-define */ + +( function ( mw, $ ) { + var parser, properties; + + /** + * Function that's useful when constructing the URI string -- we frequently encounter the pattern + * of having to add something to the URI as we go, but only if it's present, and to include a + * character before or after if so. + * + * @private + * @static + * @param {string|undefined} pre To prepend + * @param {string} val To include + * @param {string} post To append + * @param {boolean} raw If true, val will not be encoded + * @return {string} Result + */ + function cat( pre, val, post, raw ) { + if ( val === undefined || val === null || val === '' ) { + return ''; + } + + return pre + ( raw ? val : mw.Uri.encode( val ) ) + post; + } + + /** + * Regular expressions to parse many common URIs. + * + * As they are gnarly, they have been moved to separate files to allow us to format them in the + * 'extended' regular expression format (which JavaScript normally doesn't support). The subset of + * features handled is minimal, but just the free whitespace gives us a lot. + * + * @private + * @static + * @property {Object} parser + */ + parser = { + strict: mw.template.get( 'mediawiki.Uri', 'strict.regexp' ).render(), + loose: mw.template.get( 'mediawiki.Uri', 'loose.regexp' ).render() + }; + + /** + * The order here matches the order of captured matches in the `parser` property regexes. + * + * @private + * @static + * @property {Array} properties + */ + properties = [ + 'protocol', + 'user', + 'password', + 'host', + 'port', + 'path', + 'query', + 'fragment' + ]; + + /** + * @property {string} protocol For example `http` (always present) + */ + /** + * @property {string|undefined} user For example `usr` + */ + /** + * @property {string|undefined} password For example `pwd` + */ + /** + * @property {string} host For example `www.example.com` (always present) + */ + /** + * @property {string|undefined} port For example `81` + */ + /** + * @property {string} path For example `/dir/dir.2/index.htm` (always present) + */ + /** + * @property {Object} query For example `{ a: '0', b: '', c: 'value' }` (always present) + */ + /** + * @property {string|undefined} fragment For example `top` + */ + + /** + * A factory method to create a Uri class with a default location to resolve relative URLs + * against (including protocol-relative URLs). + * + * @method + * @param {string|Function} documentLocation A full url, or function returning one. + * If passed a function, the return value may change over time and this will be honoured. (T74334) + * @member mw + * @return {Function} Uri class + */ + mw.UriRelative = function ( documentLocation ) { + var getDefaultUri = ( function () { + // Cache + var href, uri; + + return function () { + var hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation(); + if ( href === hrefCur ) { + return uri; + } + href = hrefCur; + uri = new Uri( href ); + return uri; + }; + }() ); + + /** + * Construct a new URI object. Throws error if arguments are illegal/impossible, or + * otherwise don't parse. + * + * @class mw.Uri + * @constructor + * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially + * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path` + * properties. If omitted (or set to `undefined`, `null` or empty string), then an object + * will be created for the default `uri` of this constructor (`location.href` for mw.Uri, + * other values for other instances -- see mw.UriRelative for details). + * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean + * for strictMode + * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url. + * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters + * override each other (`true`) or automagically convert them to an array (`false`). + */ + function Uri( uri, options ) { + var prop, hrefCur, + hasOptions = ( options !== undefined ), + defaultUri = getDefaultUri(); + + options = typeof options === 'object' ? options : { strictMode: !!options }; + options = $.extend( { + strictMode: false, + overrideKeys: false + }, options ); + + if ( uri !== undefined && uri !== null && uri !== '' ) { + if ( typeof uri === 'string' ) { + this.parse( uri, options ); + } else if ( typeof uri === 'object' ) { + // Copy data over from existing URI object + for ( prop in uri ) { + // Only copy direct properties, not inherited ones + if ( uri.hasOwnProperty( prop ) ) { + // Deep copy object properties + if ( Array.isArray( uri[ prop ] ) || $.isPlainObject( uri[ prop ] ) ) { + this[ prop ] = $.extend( true, {}, uri[ prop ] ); + } else { + this[ prop ] = uri[ prop ]; + } + } + } + if ( !this.query ) { + this.query = {}; + } + } + } else if ( hasOptions ) { + // We didn't get a URI in the constructor, but we got options. + hrefCur = typeof documentLocation === 'string' ? documentLocation : documentLocation(); + this.parse( hrefCur, options ); + } else { + // We didn't get a URI or options in the constructor, use the default instance. + return defaultUri.clone(); + } + + // protocol-relative URLs + if ( !this.protocol ) { + this.protocol = defaultUri.protocol; + } + // No host given: + if ( !this.host ) { + this.host = defaultUri.host; + // port ? + if ( !this.port ) { + this.port = defaultUri.port; + } + } + if ( this.path && this.path[ 0 ] !== '/' ) { + // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot + // figure out whether the last path component of defaultUri.path is a directory or a file. + throw new Error( 'Bad constructor arguments' ); + } + if ( !( this.protocol && this.host && this.path ) ) { + throw new Error( 'Bad constructor arguments' ); + } + } + + /** + * Encode a value for inclusion in a url. + * + * Standard encodeURIComponent, with extra stuff to make all browsers work similarly and more + * compliant with RFC 3986. Similar to rawurlencode from PHP and our JS library + * mw.util.rawurlencode, except this also replaces spaces with `+`. + * + * @static + * @param {string} s String to encode + * @return {string} Encoded string for URI + */ + Uri.encode = function ( s ) { + return encodeURIComponent( s ) + .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' ) + .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ) + .replace( /%20/g, '+' ); + }; + + /** + * Decode a url encoded value. + * + * Reversed #encode. Standard decodeURIComponent, with addition of replacing + * `+` with a space. + * + * @static + * @param {string} s String to decode + * @return {string} Decoded string + */ + Uri.decode = function ( s ) { + return decodeURIComponent( s.replace( /\+/g, '%20' ) ); + }; + + Uri.prototype = { + + /** + * Parse a string and set our properties accordingly. + * + * @private + * @param {string} str URI, see constructor. + * @param {Object} options See constructor. + */ + parse: function ( str, options ) { + var q, matches, + uri = this, + hasOwn = Object.prototype.hasOwnProperty; + + // Apply parser regex and set all properties based on the result + matches = parser[ options.strictMode ? 'strict' : 'loose' ].exec( str ); + properties.forEach( function ( property, i ) { + uri[ property ] = matches[ i + 1 ]; + } ); + + // uri.query starts out as the query string; we will parse it into key-val pairs then make + // that object the "query" property. + // we overwrite query in uri way to make cloning easier, it can use the same list of properties. + q = {}; + // using replace to iterate over a string + if ( uri.query ) { + uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( $0, $1, $2, $3 ) { + var k, v; + if ( $1 ) { + k = Uri.decode( $1 ); + v = ( $2 === '' || $2 === undefined ) ? null : Uri.decode( $3 ); + + // If overrideKeys, always (re)set top level value. + // If not overrideKeys but this key wasn't set before, then we set it as well. + if ( options.overrideKeys || !hasOwn.call( q, k ) ) { + q[ k ] = v; + + // Use arrays if overrideKeys is false and key was already seen before + } else { + // Once before, still a string, turn into an array + if ( typeof q[ k ] === 'string' ) { + q[ k ] = [ q[ k ] ]; + } + // Add to the array + if ( Array.isArray( q[ k ] ) ) { + q[ k ].push( v ); + } + } + } + } ); + } + uri.query = q; + + // Decode uri.fragment, otherwise it gets double-encoded when serializing + if ( uri.fragment !== undefined ) { + uri.fragment = Uri.decode( uri.fragment ); + } + }, + + /** + * Get user and password section of a URI. + * + * @return {string} + */ + getUserInfo: function () { + return cat( '', this.user, cat( ':', this.password, '' ) ); + }, + + /** + * Get host and port section of a URI. + * + * @return {string} + */ + getHostPort: function () { + return this.host + cat( ':', this.port, '' ); + }, + + /** + * Get the userInfo, host and port section of the URI. + * + * In most real-world URLs this is simply the hostname, but the definition of 'authority' section is more general. + * + * @return {string} + */ + getAuthority: function () { + return cat( '', this.getUserInfo(), '@' ) + this.getHostPort(); + }, + + /** + * Get the query arguments of the URL, encoded into a string. + * + * Does not preserve the original order of arguments passed in the URI. Does handle escaping. + * + * @return {string} + */ + getQueryString: function () { + var args = []; + $.each( this.query, function ( key, val ) { + var k = Uri.encode( key ), + vals = Array.isArray( val ) ? val : [ val ]; + vals.forEach( function ( v ) { + if ( v === null ) { + args.push( k ); + } else if ( k === 'title' ) { + args.push( k + '=' + mw.util.wikiUrlencode( v ) ); + } else { + args.push( k + '=' + Uri.encode( v ) ); + } + } ); + } ); + return args.join( '&' ); + }, + + /** + * Get everything after the authority section of the URI. + * + * @return {string} + */ + getRelativePath: function () { + return this.path + cat( '?', this.getQueryString(), '', true ) + cat( '#', this.fragment, '' ); + }, + + /** + * Get the entire URI string. + * + * May not be precisely the same as input due to order of query arguments. + * + * @return {string} The URI string + */ + toString: function () { + return this.protocol + '://' + this.getAuthority() + this.getRelativePath(); + }, + + /** + * Clone this URI + * + * @return {Object} New URI object with same properties + */ + clone: function () { + return new Uri( this ); + }, + + /** + * Extend the query section of the URI with new parameters. + * + * @param {Object} parameters Query parameters to add to ours (or to override ours with) as an + * object + * @return {Object} This URI object + */ + extend: function ( parameters ) { + $.extend( this.query, parameters ); + return this; + } + }; + + return Uri; + }; + + // Default to the current browsing location (for relative URLs). + mw.Uri = mw.UriRelative( function () { + return location.href; + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.Uri/loose.regexp b/resources/src/mediawiki.Uri/loose.regexp new file mode 100644 index 0000000000..300ab3bacf --- /dev/null +++ b/resources/src/mediawiki.Uri/loose.regexp @@ -0,0 +1,22 @@ +^ +(?: + (?![^:@]+:[^:@/]*@) + (?[^:/?#.]+): +)? +(?://)? +(?:(?: + (?[^:@/?#]*) + (?::(?[^:@/?#]*))? +)?@)? +(?[^:/?#]*) +(?::(?\d*))? +( + (?:/ + (?:[^?#] + (?![^?#/]*\.[^?#/.]+(?:[?#]|$)) + )*/? + )? + [^?#/]* +) +(?:\?(?[^#]*))? +(?:\#(?.*))? diff --git a/resources/src/mediawiki.Uri/strict.regexp b/resources/src/mediawiki.Uri/strict.regexp new file mode 100644 index 0000000000..2ac7d2fce9 --- /dev/null +++ b/resources/src/mediawiki.Uri/strict.regexp @@ -0,0 +1,13 @@ +^ +(?:(?[^:/?#]+):)? +(?://(?: + (?: + (?[^:@/?#]*) + (?::(?[^:@/?#]*))? + )?@)? + (?[^:/?#]*) + (?::(?\d*))? +)? +(?(?:[^?#/]*/)*[^?#]*) +(?:\?(?[^#]*))? +(?:\#(?.*))? diff --git a/resources/src/mediawiki.apihelp.css b/resources/src/mediawiki.apihelp.css new file mode 100644 index 0000000000..d3e49500f5 --- /dev/null +++ b/resources/src/mediawiki.apihelp.css @@ -0,0 +1,105 @@ +.apihelp-header { + clear: both; + margin-bottom: 0.1em; +} + +.apihelp-header.apihelp-module-name { + /* + * This element is explicitly set to dir="ltr" in HTML. + * Set explicit alignment so that CSSJanus will flip it to "right"; + * otherwise the alignment will be automatically set to "left" according + * to the element's direction, and this will have an inconsistent look. + */ + text-align: left; +} + +div.apihelp-linktrail { + font-size: smaller; +} + +.apihelp-block { + margin-top: 0.5em; +} + +.apihelp-block-head { + font-weight: bold; +} + +.apihelp-flags { + font-size: smaller; + float: right; + border: 1px solid #000; + padding: 0.25em; + width: 20em; +} + +.apihelp-deprecated, +.apihelp-flag-deprecated, +.apihelp-flag-internal strong { + font-weight: bold; + color: #d33; +} + +.apihelp-deprecated-value { + text-decoration: line-through; +} + +.apihelp-unknown { + color: #72777d; +} + +.apihelp-empty { + color: #72777d; +} + +.apihelp-help-urls ul { + list-style-image: none; + list-style-type: none; + margin-left: 0; +} + +.apihelp-parameters dl, +.apihelp-examples dl, +.apihelp-permissions dl { + margin-left: 2em; +} + +.apihelp-parameters dt { + float: left; + clear: left; + min-width: 10em; + white-space: nowrap; + line-height: 1.5em; +} + +.apihelp-parameters dt:after { + content: ':\A0'; +} + +.apihelp-parameters dd { + margin: 0 0 0.5em 10em; + line-height: 1.5em; +} + +.apihelp-parameters dd p:first-child { + margin-top: 0; +} + +.apihelp-parameters dd.info { + margin-left: 12em; + text-indent: -2em; +} + +.apihelp-examples dt { + font-weight: normal; +} + +.api-main-links { + text-align: center; +} +.api-main-links ul:before { + content: '['; +} +.api-main-links ul:after { + content: ']'; +} diff --git a/resources/src/mediawiki.confirmCloseWindow.js b/resources/src/mediawiki.confirmCloseWindow.js new file mode 100644 index 0000000000..ee3bac246c --- /dev/null +++ b/resources/src/mediawiki.confirmCloseWindow.js @@ -0,0 +1,111 @@ +( function ( mw, $ ) { + /** + * Prevent the closing of a window with a confirm message (the onbeforeunload event seems to + * work in most browsers.) + * + * This supersedes any previous onbeforeunload handler. If there was a handler before, it is + * restored when you execute the returned release() function. + * + * var allowCloseWindow = mw.confirmCloseWindow(); + * // ... do stuff that can't be interrupted ... + * allowCloseWindow.release(); + * + * The second function returned is a trigger function to trigger the check and an alert + * window manually, e.g.: + * + * var allowCloseWindow = mw.confirmCloseWindow(); + * // ... do stuff that can't be interrupted ... + * if ( allowCloseWindow.trigger() ) { + * // don't do anything (e.g. destroy the input field) + * } else { + * // do whatever you wanted to do + * } + * + * @method confirmCloseWindow + * @member mw + * @param {Object} [options] + * @param {string} [options.namespace] Namespace for the event registration + * @param {string} [options.message] + * @param {string} options.message.return The string message to show in the confirm dialog. + * @param {Function} [options.test] + * @param {boolean} [options.test.return=true] Whether to show the dialog to the user. + * @return {Object} An object of functions to work with this module + */ + mw.confirmCloseWindow = function ( options ) { + var savedUnloadHandler, + mainEventName = 'beforeunload', + showEventName = 'pageshow', + message; + + options = $.extend( { + message: mw.message( 'mwe-prevent-close' ).text(), + test: function () { return true; } + }, options ); + + if ( options.namespace ) { + mainEventName += '.' + options.namespace; + showEventName += '.' + options.namespace; + } + + if ( $.isFunction( options.message ) ) { + message = options.message(); + } else { + message = options.message; + } + + $( window ).on( mainEventName, function () { + if ( options.test() ) { + // remove the handler while the alert is showing - otherwise breaks caching in Firefox (3?). + // but if they continue working on this page, immediately re-register this handler + savedUnloadHandler = window.onbeforeunload; + window.onbeforeunload = null; + setTimeout( function () { + window.onbeforeunload = savedUnloadHandler; + }, 1 ); + + // show an alert with this message + return message; + } + } ).on( showEventName, function () { + // Re-add onbeforeunload handler + if ( !window.onbeforeunload && savedUnloadHandler ) { + window.onbeforeunload = savedUnloadHandler; + } + } ); + + /** + * Return the object with functions to release and manually trigger the confirm alert + * + * @ignore + */ + return { + /** + * Remove all event listeners and don't show an alert anymore, if the user wants to leave + * the page. + * + * @ignore + */ + release: function () { + $( window ).off( mainEventName + ' ' + showEventName ); + }, + /** + * Trigger the module's function manually: Check, if options.test() returns true and show + * an alert to the user if he/she want to leave this page. Returns false, if options.test() returns + * false or the user cancelled the alert window (~don't leave the page), true otherwise. + * + * @ignore + * @return {boolean} + */ + trigger: function () { + // use confirm to show the message to the user (if options.text() is true) + // eslint-disable-next-line no-alert + if ( options.test() && !confirm( message ) ) { + // the user want to keep the actual page + return false; + } + // otherwise return true + return true; + } + }; + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.content.json.less b/resources/src/mediawiki.content.json.less new file mode 100644 index 0000000000..e084ab81c2 --- /dev/null +++ b/resources/src/mediawiki.content.json.less @@ -0,0 +1,59 @@ +/*! + * CSS for styling HTML-formatted JSON Schema objects + * + * @file + * @author Munaf Assaf + */ + +.mw-json { + border-collapse: collapse; + border-spacing: 0; + font-style: normal; +} + +.mw-json th, +.mw-json td { + border: 1px solid #72777d; + font-size: 16px; + padding: 0.5em 1em; +} + +.mw-json .value, +.mw-json-single-value { + background-color: #dcfae3; + font-family: monospace, monospace; + white-space: pre-wrap; +} + +.mw-json-single-value { + background-color: #eaecf0; +} + +.mw-json-empty { + background-color: #fff; + font-style: italic; +} + +.mw-json tr { + background-color: #eaecf0; + margin-bottom: 0.5em; +} + +.mw-json th { + background-color: #fff; + font-weight: normal; +} + +.mw-json caption { + /* For stylistic reasons, suppress the caption of the outermost table */ + display: none; +} + +.mw-json table caption { + color: #72777d; + display: inline-block; + font-size: 10px; + font-style: italic; + margin-bottom: 0.5em; + text-align: left; +} diff --git a/resources/src/mediawiki.debug/debug.js b/resources/src/mediawiki.debug/debug.js new file mode 100644 index 0000000000..830ff339f6 --- /dev/null +++ b/resources/src/mediawiki.debug/debug.js @@ -0,0 +1,398 @@ +( function ( mw, $ ) { + 'use strict'; + + var debug, + hovzer = $.getFootHovzer(); + + OO.ui.getViewportSpacing = function () { + return { + top: 0, + right: 0, + bottom: hovzer.$.outerHeight(), + left: 0 + }; + }; + + /** + * Debug toolbar. + * + * Enabled server-side through `$wgDebugToolbar`. + * + * @class mw.Debug + * @singleton + * @author John Du Hart + * @since 1.19 + */ + debug = mw.Debug = { + /** + * Toolbar container element + * + * @property {jQuery} + */ + $container: null, + + /** + * Object containing data for the debug toolbar + * + * @property {Object} + */ + data: {}, + + /** + * Initialize the debugging pane + * + * Shouldn't be called before the document is ready + * (since it binds to elements on the page). + * + * @param {Object} [data] Defaults to 'debugInfo' from mw.config + */ + init: function ( data ) { + + this.data = data || mw.config.get( 'debugInfo' ); + this.buildHtml(); + + // Insert the container into the DOM + hovzer.$.append( this.$container ); + hovzer.update(); + + $( '.mw-debug-panelink' ).click( this.switchPane ); + }, + + /** + * Switch between panes + * + * Should be called with an HTMLElement as its thisArg, + * because it's meant to be an event handler. + * + * TODO: Store cookie for last pane open. + * + * @param {jQuery.Event} e + */ + switchPane: function ( e ) { + var currentPaneId = debug.$container.data( 'currentPane' ), + requestedPaneId = $( this ).prop( 'id' ).slice( 9 ), + $currentPane = $( '#mw-debug-pane-' + currentPaneId ), + $requestedPane = $( '#mw-debug-pane-' + requestedPaneId ), + hovDone = false; + + function updateHov() { + if ( !hovDone ) { + hovzer.update(); + hovDone = true; + } + } + + // Skip hash fragment handling. Prevents screen from jumping. + e.preventDefault(); + + $( this ).addClass( 'current ' ); + $( '.mw-debug-panelink' ).not( this ).removeClass( 'current ' ); + + // Hide the current pane + if ( requestedPaneId === currentPaneId ) { + $currentPane.slideUp( updateHov ); + debug.$container.data( 'currentPane', null ); + return; + } + + debug.$container.data( 'currentPane', requestedPaneId ); + + if ( currentPaneId === undefined || currentPaneId === null ) { + $requestedPane.slideDown( updateHov ); + } else { + $currentPane.hide(); + $requestedPane.show(); + updateHov(); + } + }, + + /** + * Construct the HTML for the debugging toolbar + */ + buildHtml: function () { + var $container, $bits, panes, id, gitInfo; + + $container = $( '

    ' ); + + $bits = $( '
    ' ); + + /** + * Returns a jQuery element for a debug-bit div + * + * @ignore + * @param {string} id + * @return {jQuery} + */ + function bitDiv( id ) { + return $( '
    ' ).prop( { + id: 'mw-debug-' + id, + className: 'mw-debug-bit' + } ).appendTo( $bits ); + } + + /** + * Returns a jQuery element for a pane link + * + * @ignore + * @param {string} id + * @param {string} text + * @return {jQuery} + */ + function paneLabel( id, text ) { + return $( '' ) + .prop( { + className: 'mw-debug-panelabel', + href: '#mw-debug-pane-' + id + } ) + .text( text ); + } + + /** + * Returns a jQuery element for a debug-bit div with a for a pane link + * + * @ignore + * @param {string} id CSS id snippet. Will be prefixed with 'mw-debug-' + * @param {string} text Text to show + * @param {string} count Optional count to show + * @return {jQuery} + */ + function paneTriggerBitDiv( id, text, count ) { + if ( count ) { + text = text + ' (' + count + ')'; + } + return $( '
    ' ).prop( { + id: 'mw-debug-' + id, + className: 'mw-debug-bit mw-debug-panelink' + } ) + .append( paneLabel( id, text ) ) + .appendTo( $bits ); + } + + paneTriggerBitDiv( 'console', 'Console', this.data.log.length ); + + paneTriggerBitDiv( 'querylist', 'Queries', this.data.queries.length ); + + paneTriggerBitDiv( 'debuglog', 'Debug log', this.data.debugLog.length ); + + paneTriggerBitDiv( 'request', 'Request' ); + + paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length ); + + gitInfo = ''; + if ( this.data.gitRevision !== false ) { + gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')'; + if ( this.data.gitViewUrl !== false ) { + gitInfo = $( '' ) + .attr( 'href', this.data.gitViewUrl ) + .text( gitInfo ); + } + } + + bitDiv( 'mwversion' ) + .append( $( 'MediaWiki' ) ) + .append( document.createTextNode( ': ' + this.data.mwVersion + ' ' ) ) + .append( gitInfo ); + + if ( this.data.gitBranch !== false ) { + bitDiv( 'gitbranch' ).text( 'Git branch: ' + this.data.gitBranch ); + } + + bitDiv( 'phpversion' ) + .append( $( this.data.phpEngine === 'HHVM' ? + 'HHVM' : + 'PHP' + ) ) + .append( ': ' + this.data.phpVersion ); + + bitDiv( 'time' ) + .text( 'Time: ' + this.data.time.toFixed( 5 ) ); + + bitDiv( 'memory' ) + .text( 'Memory: ' + this.data.memory + ' (Peak: ' + this.data.memoryPeak + ')' ); + + $bits.appendTo( $container ); + + panes = { + console: this.buildConsoleTable(), + querylist: this.buildQueryTable(), + debuglog: this.buildDebugLogTable(), + request: this.buildRequestPane(), + includes: this.buildIncludesPane() + }; + + for ( id in panes ) { + if ( !panes.hasOwnProperty( id ) ) { + continue; + } + + $( '
    ' ) + .prop( { + className: 'mw-debug-pane', + id: 'mw-debug-pane-' + id + } ) + .append( panes[ id ] ) + .appendTo( $container ); + } + + this.$container = $container; + }, + + /** + * Build the console panel + * + * @return {jQuery} Console panel + */ + buildConsoleTable: function () { + var $table, entryTypeText, i, length, entry; + + $table = $( '' ); + + $( '' ).css( 'width', /* padding = */ 20 + ( 10 * /* fontSize = */ 11 ) ).appendTo( $table ); + $( '' ).appendTo( $table ); + $( '' ).css( 'width', 350 ).appendTo( $table ); + + entryTypeText = function ( entryType ) { + switch ( entryType ) { + case 'log': + return 'Log'; + case 'warn': + return 'Warning'; + case 'deprecated': + return 'Deprecated'; + default: + return 'Unknown'; + } + }; + + for ( i = 0, length = this.data.log.length; i < length; i += 1 ) { + entry = this.data.log[ i ]; + entry.typeText = entryTypeText( entry.type ); + + $( '' ) + .append( $( '' ) + .append( $( '' ).css( 'width', '4em' ) ) + .append( $( '' ) ) + .append( $( '' ).css( 'width', '8em' ) ) + .append( $( '' ).css( 'width', '18em' ) ) + .appendTo( $table ); + + for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) { + query = this.data.queries[ i ]; + + $( '' ) + .append( $( '
    ' ) + .text( entry.typeText ) + .addClass( 'mw-debug-console-' + entry.type ) + ) + .append( $( '' ).html( entry.msg ) ) + .append( $( '' ).text( entry.caller ) ) + .appendTo( $table ); + } + + return $table; + }, + + /** + * Build query list pane + * + * @return {jQuery} + */ + buildQueryTable: function () { + var $table, i, length, query; + + $table = $( '
    ' ); + + $( '
    #SQLTimeCall
    ' ).text( i + 1 ) ) + .append( $( '' ).text( query.sql ) ) + .append( $( '' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) ) + .append( $( '' ).text( query[ 'function' ] ) ) + .appendTo( $table ); + } + + return $table; + }, + + /** + * Build legacy debug log pane + * + * @return {jQuery} + */ + buildDebugLogTable: function () { + var $list, i, length, line; + $list = $( '
      ' ); + + for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) { + line = this.data.debugLog[ i ]; + $( '
    • ' ) + .html( mw.html.escape( line ).replace( /\n/g, '
      \n' ) ) + .appendTo( $list ); + } + + return $list; + }, + + /** + * Build request information pane + * + * @return {jQuery} + */ + buildRequestPane: function () { + + function buildTable( title, data ) { + var $unit, $table, key; + + $unit = $( '
      ' ).append( $( '

      ' ).text( title ) ); + + $table = $( '' ).appendTo( $unit ); + + $( '' ) + .html( '' ) + .appendTo( $table ); + + for ( key in data ) { + if ( !data.hasOwnProperty( key ) ) { + continue; + } + + $( '' ) + .append( $( '
      KeyValue
      ' ).text( key ) ) + .append( $( '' ).text( data[ key ] ) ) + .appendTo( $table ); + } + + return $unit; + } + + return $( '
      ' ) + .text( this.data.request.method + ' ' + this.data.request.url ) + .append( buildTable( 'Headers', this.data.request.headers ) ) + .append( buildTable( 'Parameters', this.data.request.params ) ); + }, + + /** + * Build included files pane + * + * @return {jQuery} + */ + buildIncludesPane: function () { + var $table, i, length, file; + + $table = $( '' ); + + for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) { + file = this.data.includes[ i ]; + $( '' ) + .append( $( '
      ' ).text( file.name ) ) + .append( $( '' ).text( file.size ) ) + .appendTo( $table ); + } + + return $table; + } + }; + + $( function () { + debug.init(); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.debug/debug.less b/resources/src/mediawiki.debug/debug.less new file mode 100644 index 0000000000..a56e4592a2 --- /dev/null +++ b/resources/src/mediawiki.debug/debug.less @@ -0,0 +1,187 @@ +.mw-debug { + width: 100%; + background-color: #eee; + border-top: 1px solid #aaa; + + pre { + font-size: 11px; + padding: 0; + margin: 0; + background: none; + border: 0; + } + + table { + border-spacing: 0; + width: 100%; + table-layout: fixed; + + tr { + background-color: #fff; + + &:nth-child( even ) { + background-color: #f9f9f9; + } + } + + td, + th { + padding: 4px 10px; + } + + td { + border-bottom: 1px solid #eee; + word-wrap: break-word; + + &.nr { + text-align: right; + } + + span.stats { + color: #727272; + } + } + } + + ul { + margin: 0; + list-style: none; + } + + li { + padding: 4px 0; + width: 100%; + } +} + +.mw-debug-bits { + text-align: center; + border-bottom: 1px solid #aaa; +} + +.mw-debug-bit { + display: inline-block; + padding: 10px 5px; + font-size: 13px; +} + +.mw-debug-panelink { + background-color: #eee; + border-right: 1px solid #ccc; + + &:first-child { + border-left: 1px solid #ccc; + } + + &:hover { + background-color: #fefefe; + cursor: pointer; + } + + &.current { + background-color: #dedede; + } +} + +a.mw-debug-panelabel, +a.mw-debug-panelabel:visited { + color: #000; +} + +.mw-debug-pane { + height: 300px; + overflow: scroll; + display: none; + font-family: monospace, monospace; + font-size: 11px; + background-color: #e1eff2; + box-sizing: border-box; +} + +#mw-debug-pane-debuglog, +#mw-debug-pane-request { + padding: 20px; +} + +#mw-debug-pane-request { + table { + width: 100%; + margin: 10px 0 30px; + } + + tr, + th, + td, + table { + border: 1px solid #d0dbb3; + border-collapse: collapse; + margin: 0; + } + + th, + td { + font-size: 12px; + padding: 8px 10px; + } + + th { + background-color: #f1f7e2; + font-weight: bold; + } + + td { + background-color: #fff; + } +} + +#mw-debug-console tr td { + &:first-child { + font-weight: bold; + vertical-align: top; + } + + &:last-child { + vertical-align: top; + } +} + +.mw-debug-backtrace { + padding: 5px 10px; + margin: 5px; + background-color: #dedede; + + span { + font-weight: bold; + color: #111; + } + + ul { + padding-left: 10px; + } + + li { + width: auto; + padding: 0; + color: #333; + font-size: 10px; + margin-bottom: 0; + line-height: 1em; + } +} + +.mw-debug-console-log { + background-color: #add8e6; +} + +.mw-debug-console-warn { + background-color: #ffa07a; +} + +.mw-debug-console-deprecated { + background-color: #ffb6c1; +} + +/* Cheapo hack to hide the first 3 lines of the backtrace */ +.mw-debug-backtrace li:nth-child( -n+3 ) { + display: none; +} diff --git a/resources/src/mediawiki.diff.styles/diff.css b/resources/src/mediawiki.diff.styles/diff.css new file mode 100644 index 0000000000..c46922257f --- /dev/null +++ b/resources/src/mediawiki.diff.styles/diff.css @@ -0,0 +1,164 @@ +/*! + * Diff rendering + */ + +.diff { + border: 0; + border-spacing: 4px; + margin: 0; + width: 100%; + /* Ensure that colums are of equal width */ + table-layout: fixed; +} + +.diff td { + padding: 0.33em 0.5em; +} + +.diff td.diff-marker { + /* Compensate padding for increased font-size */ + padding: 0.25em; +} + +.diff col.diff-marker { + width: 2%; +} + +.diff .diff-content { + width: 48%; +} + +.diff td div { + /* Force-wrap very long lines such as URLs or page-widening char strings */ + word-wrap: break-word; +} + +.diff-title { + vertical-align: top; +} + +.diff-notice, +.diff-multi, +.diff-otitle, +.diff-ntitle { + text-align: center; +} + +.diff-lineno { + font-weight: bold; +} + +td.diff-marker { + text-align: right; + font-weight: bold; + font-size: 1.25em; + line-height: 1.2; +} + +.diff-addedline, +.diff-deletedline, +.diff-context { + font-size: 88%; + line-height: 1.6; + vertical-align: top; + white-space: -moz-pre-wrap; + white-space: pre-wrap; + border-style: solid; + border-width: 1px 1px 1px 4px; + border-radius: 0.33em; +} + +.diff-addedline { + border-color: #a3d3ff; +} + +.diff-deletedline { + border-color: #ffe49c; +} + +.diff-context { + background: #f8f9fa; + border-color: #eaecf0; + color: #222; +} + +.diffchange { + font-weight: bold; + text-decoration: none; +} + +.diff-addedline .diffchange, +.diff-deletedline .diffchange { + border-radius: 0.33em; + padding: 0.25em 0; +} + +.diff-addedline .diffchange { + background: #d8ecff; +} + +.diff-deletedline .diffchange { + background: #feeec8; +} + +/* Correct user & content directionality when viewing a diff */ +.diff-currentversion-title, +.diff { + direction: ltr; + unicode-bidi: embed; +} + +/* @noflip */ .diff-contentalign-right td { + direction: rtl; + unicode-bidi: embed; +} + +/* @noflip */ .diff-contentalign-left td { + direction: ltr; + unicode-bidi: embed; +} + +.diff-multi, +.diff-otitle, +.diff-ntitle, +.diff-lineno { + direction: ltr !important; /* stylelint-disable-line declaration-no-important */ + unicode-bidi: embed; +} + +/*! + * Wikidiff2 rendering for moved paragraphs + */ + +.mw-diff-movedpara-left, +.mw-diff-movedpara-right, +.mw-diff-movedpara-left:visited, +.mw-diff-movedpara-right:visited, +.mw-diff-movedpara-left:active, +.mw-diff-movedpara-right:active { + display: block; + color: transparent; +} + +.mw-diff-movedpara-left:hover, +.mw-diff-movedpara-right:hover { + text-decoration: none; + color: transparent; +} + +.mw-diff-movedpara-left:after, +.mw-diff-movedpara-right:after { + display: block; + color: #222; + margin-top: -1.25em; +} + +.mw-diff-movedpara-left:after, +.rtl .mw-diff-movedpara-right:after { + content: '↪'; +} + +.mw-diff-movedpara-right:after, +.rtl .mw-diff-movedpara-left:after { + content: '↩'; +} diff --git a/resources/src/mediawiki.diff.styles/print.css b/resources/src/mediawiki.diff.styles/print.css new file mode 100644 index 0000000000..76b5c9b7ae --- /dev/null +++ b/resources/src/mediawiki.diff.styles/print.css @@ -0,0 +1,16 @@ +/*! + * Diff rendering + */ +td.diff-context, +td.diff-addedline .diffchange, +td.diff-deletedline .diffchange { + background-color: transparent; +} + +td.diff-addedline .diffchange { + text-decoration: underline; +} + +td.diff-deletedline .diffchange { + text-decoration: line-through; +} diff --git a/resources/src/mediawiki.editfont.less b/resources/src/mediawiki.editfont.less new file mode 100644 index 0000000000..b8e127a26d --- /dev/null +++ b/resources/src/mediawiki.editfont.less @@ -0,0 +1,30 @@ +/* Edit font preference */ +.mw-editfont-monospace { + font-family: monospace, monospace; +} + +.mw-editfont-sans-serif { + font-family: sans-serif; +} + +.mw-editfont-serif { + font-family: serif; +} + +/* Standardize font size for edit areas using edit-fonts T182320 */ +.mw-editfont-monospace, +.mw-editfont-sans-serif, +.mw-editfont-serif { + font-size: 13px; + + /* For OOUI TextInputWidget, the parent
      element uses normal font size, and only + the